diff --git a/CADability/ExportDxf.cs b/CADability/ExportDxf.cs index 217f4da2..0335f36b 100644 --- a/CADability/ExportDxf.cs +++ b/CADability/ExportDxf.cs @@ -697,9 +697,11 @@ private Entity ExportText(GeoObject.Text text) if (text.Bold) fs |= System.Drawing.FontStyle.Bold; if (text.Italic) fs |= System.Drawing.FontStyle.Italic; - // TextSize is the raw DXF cap-height (group 40) stored unchanged during import. - // Do not apply any font-metric scaling here — it would corrupt the value. - double height = text.TextSize; + // TextSize is the em height of the font, while the DXF height (group 40) is the + // height of the capital letters, which AutoCAD and other viewers scale the font to. + // Writing TextSize unchanged makes the text 1.4 (Arial) to 1.6 (Consolas) times too + // large in every other program (issue #506). The import divides by the same factor. + double height = text.TextSize * text.CapHeightFactor; string fontName = text.Font ?? "Standard"; if (!createdTextStyles.TryGetValue(fontName, out ACadSharp.Tables.TextStyle textStyle)) @@ -765,6 +767,10 @@ private Entity ExportText(GeoObject.Text text) Normal = ToXYZ(normal), Rotation = rotation, }; + // Group 41: CADability stretches or compresses a text by the length of its line + // direction relative to its glyph direction. + double widthFactor = text.LineDirection.Length / text.GlyphDirection.Length; + if (widthFactor > 0.0 && Math.Abs(widthFactor - 1.0) > 1e-9) res.WidthFactor = widthFactor; // Set group 11 whenever it is meaningful. Leaving it at its (0,0,0) default while // groups 72/73 say it is authoritative would place the text at the origin. if (!defaultAlign) @@ -814,9 +820,10 @@ private ACadSharp.Entities.MText BuildMText(GeoObject.Text text, string textStri if (text.ColumnWidth > 0) res.RectangleWidth = text.ColumnWidth; if (text.LineSpacing > 0) { - // Import stores the DXF spacing factor as 5/3 * factor (AutoCAD's MTEXT - // baseline distance); invert that here and keep it inside the DXF range. - double factor = text.LineSpacing * 3.0 / 5.0; + // AutoCAD spaces MTEXT baselines 5/3 * factor of the DXF height apart, and the + // import converts that into a factor of TextSize; invert that here and keep it + // inside the DXF range. + double factor = text.LineSpacing * 3.0 / 5.0 / text.CapHeightFactor; res.LineSpacing = Math.Max(0.25, Math.Min(4.0, factor)); // Group 73 must name a real spacing style, otherwise the factor in group 44 // is ignored; the default (None/0) is not one the DXF reference allows here. @@ -1121,7 +1128,10 @@ private ACadSharp.Tables.DimensionStyle GetOrCreateDimensionStyle(CADability.Att found = new ACadSharp.Tables.DimensionStyle(candidate); doc.DimensionStyles.Add(found); } - if (style.TextSize > 0.0) found.TextHeight = style.TextSize; + // DIMTXT is the height of the capital letters like the height of a TEXT, while + // CADability draws the dimension text with TextSize as the em height (issue #506). + if (style.TextSize > 0.0) + found.TextHeight = style.TextSize * GeoObject.Text.GetCapHeightFactor(style.TextFont, false, false); found.ArrowSize = Math.Max(0.0, style.SymbolSize); found.ExtensionLineOffset = style.ExtLineOffset; found.ExtensionLineExtension = style.ExtLineExtension; diff --git a/CADability/ImportDxf.cs b/CADability/ImportDxf.cs index 4da4e903..a3a6a4ed 100644 --- a/CADability/ImportDxf.cs +++ b/CADability/ImportDxf.cs @@ -1907,6 +1907,9 @@ private IGeoObject CreateText(ACadSharp.Entities.TextEntity txt, bool pointsInWc text.Location = pointsInWcs ? GeoPoint(anchor) : OcsToWcs(anchor, txt.Normal); text.LineDirection = linedir; text.GlyphDirection = glyphdir; + // The DXF height is the height of the capital letters, TextSize the em height of + // the font (issue #506). The export multiplies by the same factor. + h /= text.CapHeightFactor; text.TextSize = h; linedir.Length = h * txt.WidthFactor; if (!linedir.IsNullVector()) text.LineDirection = linedir; @@ -2273,8 +2276,6 @@ private void MapDimensionStyle(ACadSharp.Tables.DimensionStyle acad, { double scale = acad.ScaleFactor > 0.0 ? acad.ScaleFactor : 1.0; style.Types = Attribute.DimensionStyle.ETypeFlag.DimAllTypes; - style.TextSize = acad.TextHeight * scale; - style.TextSizeTol = acad.TextHeight * acad.ToleranceScaleFactor * scale; style.SymbolSize = acad.ArrowSize * scale; style.ExtLineOffset = acad.ExtensionLineOffset * scale; style.ExtLineExtension = acad.ExtensionLineExtension * scale; @@ -2299,12 +2300,17 @@ private void MapDimensionStyle(ACadSharp.Tables.DimensionStyle acad, style.TextPrefix = arcSymbol + (acad.Prefix ?? ""); style.TextPostfix = acad.Suffix ?? ""; style.TextFont = FontNameFromStyle(acad.Style); + // DIMTXT is the height of the capital letters like the height of a TEXT, while + // CADability draws the dimension text with TextSize as the em height (issue #506). + double emHeight = acad.TextHeight * scale / GeoObject.Text.GetCapHeightFactor(style.TextFont, false, false); + style.TextSize = emHeight; + style.TextSizeTol = emHeight * acad.ToleranceScaleFactor; // DIMTAD says whether the text sits on the dimension line or above it, DIMGAP how - // far; CADability measures that distance in text heights. + // far; CADability measures that distance in multiples of TextSize. style.TextDist = acad.TextVerticalAlignment == DimensionTextVerticalAlignment.Centered - || acad.TextHeight <= 0.0 + || emHeight <= 0.0 ? 0.0 - : Math.Abs(acad.DimensionLineGap) / acad.TextHeight; + : Math.Abs(acad.DimensionLineGap) * scale / emHeight; style.Symbol = MapArrowSymbol(acad); style.DimNoExtLine1 = acad.SuppressFirstExtensionLine; style.DimNoExtLine2 = acad.SuppressSecondExtensionLine; @@ -2559,9 +2565,10 @@ private IGeoObject CreateMText(ACadSharp.Entities.MText mText) // Automatic word wrap at the MTEXT column width (DXF group code 41) if (mText.RectangleWidth > 0) text.ColumnWidth = mText.RectangleWidth; // AutoCAD spaces MTEXT baselines 5/3 of the text height apart, scaled by the - // file's line spacing factor (DXF group code 44, default 1.0) + // file's line spacing factor (DXF group code 44, default 1.0). The text height + // is that of the capital letters, LineSpacing a factor of the em height. double factor = mText.LineSpacing > 0 ? mText.LineSpacing : 1.0; - text.LineSpacing = 5.0 / 3.0 * factor; + text.LineSpacing = 5.0 / 3.0 * factor * text.CapHeightFactor; } return geo; } diff --git a/CADability/Text.cs b/CADability/Text.cs index 0e171b09..7c944a7e 100644 --- a/CADability/Text.cs +++ b/CADability/Text.cs @@ -477,6 +477,7 @@ struct FontCharacteristics readonly Dictionary centerLineCache; // Cache für die CenterLines, Größe 1 readonly Dictionary fontCharacteristicsCache; // Cache für die FontCharacteristics readonly Dictionary faces; // the faces of FontProvider.Current, per font name and bold/italic + readonly Dictionary capHeights; // see GetCapHeight public FontCache() { FontPrecision = Settings.GlobalSettings.GetIntValue("Font.Precision", 1); // |grob|mittel|fein @@ -485,6 +486,7 @@ public FontCache() centerLineCache = new Dictionary(); fontCharacteristicsCache = new Dictionary(); faces = new Dictionary(); + capHeights = new Dictionary(); } /// /// Returns the face of for a font name and a font style (the flags of @@ -521,6 +523,39 @@ internal FontFace TryGetFace(string fontName, int fontStyle) return null; } } + /// + /// The height of the capital letters of a font in units of the em height, measured at the outline of "H". A DXF or + /// DWG text height (group 40) is this height, while is the em height (issue #506). + /// Returns 1.0 when there is no font at all or the font has no "H", then both heights are taken to be the same. + /// + internal double GetCapHeight(string fontName, int fontStyle) + { + FontKey key = new FontKey(fontName ?? "", fontStyle & 3); + lock (this) + { + if (capHeights.TryGetValue(key, out double res)) return res; + res = 1.0; + FontFace face = TryGetFace(fontName, fontStyle & 3); + if (face != null && face.EmHeight > 0) + { + try + { // the outline is y downwards with the baseline at CellAscent/EmHeight, see FontFace.GetGlyphPath + face.GetGlyphPath('H', false, false, out PointF[] points, out byte[] _); + if (points.Length > 0) + { + double top = points.Min(p => p.Y); + double height = (double)face.CellAscent / face.EmHeight - top; + if (height > 0.0 && height <= 2.0) res = height; + } + } + catch (Exception ex) when (!(ex is ThreadAbortException)) + { // a glyph that cannot be read leaves the heights the same + } + } + capHeights[key] = res; + return res; + } + } // The caches are shared by all threads that measure or paint text, and Clear may empty them at any time: every access // goes through lock (this). The lock is reentrant, Get and GetCenterLines call GetOutline2D. The faces are locked // separately and always after this, so the two locks are taken in the same order everywhere. @@ -754,6 +789,7 @@ public void Clear() cache.Clear(); centerLineCache.Clear(); fontCharacteristicsCache.Clear(); + capHeights.Clear(); lock (faces) faces.Clear(); } } @@ -2358,6 +2394,23 @@ public double TextSize } } /// + /// The height of the capital letters of the font of this text as a fraction of , which is the + /// em height. DXF and DWG give the height of a text as the height of its capital letters, so import and export + /// convert with this factor (issue #506). + /// + internal double CapHeightFactor => GetCapHeightFactor(fontName, Bold, Italic); + /// + /// The height of the capital letters of the font as a fraction of its em height, see + /// . Dimensions use it for the text size of their style. + /// + internal static double GetCapHeightFactor(string fontName, bool bold, bool italic) + { + int fs = 0; + if (bold) fs |= (int)FontStyle.Bold; + if (italic) fs |= (int)FontStyle.Italic; + return FontCache.GlobalFontCache.GetCapHeight(fontName, fs); + } + /// /// Width of the text column for automatic word wrapping (in world units, e.g. from a DXF MTEXT). /// The displayed text is broken into additional lines at word boundaries so that no line gets /// wider than this value. 0.0 (the default) means no automatic wrapping, only explicit line diff --git a/tests/CADability.ImportTests/DxfDimensionTest.cs b/tests/CADability.ImportTests/DxfDimensionTest.cs index 4a37aa85..5e31ae14 100644 --- a/tests/CADability.ImportTests/DxfDimensionTest.cs +++ b/tests/CADability.ImportTests/DxfDimensionTest.cs @@ -565,7 +565,9 @@ public void export_dimension_writes_a_dxf_dimension() "the measurement text"); } Assert.AreEqual(project.DimensionStyleList.Current.Name, linear.Style.Name); - Assert.AreEqual(project.DimensionStyleList.Current.TextSize, linear.Style.TextHeight, 1e-12); + CADability.Attribute.DimensionStyle current = project.DimensionStyleList.Current; + Assert.AreEqual(CapitalHeight(current.TextFont, current.TextSize), linear.Style.TextHeight, 1e-8, + "DIMTXT is the height of the capital letters CADability draws (issue #506)"); } /// @@ -981,7 +983,8 @@ public void import_dxf_dimension_translates_the_dimension_style() Assert.IsNotNull(style, "a rebuilt dimension needs a style to draw with"); Assert.AreEqual("LOGICAL", style.Name, "the style should keep the name from the file"); Assert.AreSame(style, project.DimensionStyleList.Find("LOGICAL"), "the style is part of the project"); - Assert.AreEqual(7.0, style.TextSize, 1e-8, "DIMTXT 3.5 times DIMSCALE 2"); + Assert.AreEqual(7.0, CapitalHeight(style.TextFont, style.TextSize), 1e-8, + "capital letters as high as DIMTXT 3.5 times DIMSCALE 2 (issue #506)"); Assert.AreEqual(3.0, style.SymbolSize, 1e-8, "DIMASZ 1.5 times DIMSCALE 2"); Assert.AreEqual(1.0, style.ExtLineOffset, 1e-8, "DIMEXO 0.5 times DIMSCALE 2"); Assert.AreEqual(2.5, style.ExtLineExtension, 1e-8, "DIMEXE 1.25 times DIMSCALE 2"); @@ -1002,7 +1005,8 @@ public void import_dxf_dimension_uses_the_standard_style_of_the_file() Project project = ImportProjectAsDimensions(StyledPrologue + standard + Epilogue); Dimension dim = SingleDimensionObject(project.GetActiveModel()); - Assert.AreEqual(0.18, dim.DimensionStyle.TextSize, 1e-8, "DIMTXT of AutoCAD's Standard style"); + Assert.AreEqual(0.18, CapitalHeight(dim.DimensionStyle.TextFont, dim.DimensionStyle.TextSize), 1e-8, + "capital letters as high as DIMTXT of AutoCAD's Standard style"); Assert.AreEqual(0.18, dim.DimensionStyle.SymbolSize, 1e-8, "DIMASZ of AutoCAD's Standard style"); } @@ -1047,7 +1051,8 @@ public void import_dxf_dimension_style_override_does_not_leak() Dimension[] dims = model.AllObjects.OfType().ToArray(); Assert.AreEqual(2, dims.Length, "both dimensions should be rebuilt"); - double[] sizes = dims.Select(d => d.DimensionStyle.TextSize).OrderBy(t => t).ToArray(); + double[] sizes = dims.Select(d => CapitalHeight(d.DimensionStyle.TextFont, d.DimensionStyle.TextSize)) + .OrderBy(t => t).ToArray(); Assert.AreEqual(7.0, sizes[0], 1e-8, "the plain dimension keeps DIMTXT 3.5 times DIMSCALE 2"); Assert.AreEqual(18.0, sizes[1], 1e-8, "the overridden one gets 9.0 times DIMSCALE 2"); Assert.AreNotSame(dims[0].DimensionStyle, dims[1].DimensionStyle, @@ -1603,6 +1608,23 @@ private Model ExportAndImport(Project project) private static double Distance(CSMath.XYZ p, GeoPoint q) => new GeoPoint(p.X, p.Y, p.Z) | q; + /// + /// The height of an "H" that CADability draws in with the text size + /// , which is the em height. DIMTXT is this height (issue #506). + /// + private static double CapitalHeight(string font, double textSize) + { + Text text = Text.Construct(); + text.Font = font; + text.TextString = "H"; + text.LineDirection = GeoVector.XAxis; + text.GlyphDirection = GeoVector.YAxis; + text.TextSize = textSize; + BoundingCube ext = BoundingCube.EmptyBoundingCube; + foreach (IGeoObject go in text.GetOutline()) ext.MinMax(go.GetBoundingCube()); + return ext.Ymax - ext.Ymin; + } + private static Block SingleDimensionBlock(Model model) { Assert.AreEqual(1, model.Count, "the DIMENSION should import as one object"); diff --git a/tests/CADability.ImportTests/DxfTextHeightTest.cs b/tests/CADability.ImportTests/DxfTextHeightTest.cs new file mode 100644 index 00000000..bacf8cb8 --- /dev/null +++ b/tests/CADability.ImportTests/DxfTextHeightTest.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using CADability; +using CADability.GeoObject; + +namespace CADability.ImportTests +{ + /// + /// Regression for issue #506: the height of a DXF TEXT (group 40) is the height of the capital + /// letters, which AutoCAD and other viewers scale the font to, while + /// is the em height of the font. The exporter wrote TextSize unchanged, so every other program + /// showed the text 1.4 (Arial) to 1.6 (Consolas) times larger than CADability and its bounding + /// box. The font is Liberation Sans, which the tests register (see ). + /// + [TestClass] + public class DxfTextHeightTest + { + public TestContext TestContext { get; set; } + + private const string Font = "Liberation Sans"; + + [TestMethod] + public void export_dxf_text_height_is_the_height_of_the_capital_letters() + { + Text text = MakeText("H", 12.0); + double capitalHeight = OutlineHeight(text); + Assert.IsTrue(capitalHeight > 6.0 && capitalHeight < 10.0, "test setup: an H is about 0.7 em high"); + + ACadSharp.Entities.TextEntity written = ReadSingleText(Export(text)); + Assert.AreEqual(capitalHeight, written.Height, 1e-6, "group 40 is the height of the H CADability draws"); + Assert.AreEqual(1.0, written.WidthFactor, 1e-12, "group 41 of an unstretched text"); + } + + [TestMethod] + public void import_dxf_text_draws_capital_letters_as_high_as_the_text_height() + { + ACadSharp.CadDocument doc = new ACadSharp.CadDocument(); + ACadSharp.Tables.TextStyle style = new ACadSharp.Tables.TextStyle(Font) { Filename = Font + ".ttf" }; + doc.TextStyles.Add(style); + doc.Entities.Add(new ACadSharp.Entities.TextEntity + { + Value = "H", + Height = 10.0, + Style = style, + InsertPoint = new CSMath.XYZ(5, 7, 0), + }); + string file = this.TestContext.TestName + ".dxf"; + using (ACadSharp.IO.DxfWriter writer = new ACadSharp.IO.DxfWriter(file, doc, false)) writer.Write(); + + Text text = ReimportSingle(file); + Assert.AreEqual(Font, text.Font); + Assert.AreEqual(10.0, OutlineHeight(text), 1e-6, "the imported H is as high as group 40"); + } + + [TestMethod] + public void export_dxf_text_round_trip_keeps_size_width_and_extent() + { + Text text = MakeText("only 6 days left", 12.0); + // stretched to twice the width + text.LineDirection = 2.0 * text.LineDirection; + BoundingCube extent = text.GetBoundingCube(); + + string file = Export(text); + Assert.AreEqual(2.0, ReadSingleText(file).WidthFactor, 1e-9, "group 41 carries the stretch"); + + Text back = ReimportSingle(file); + Assert.AreEqual(text.TextSize, back.TextSize, 1e-6, "TextSize"); + Assert.AreEqual(text.LineDirection.Length, back.LineDirection.Length, 1e-6, "length of the line direction"); + BoundingCube backExtent = back.GetBoundingCube(); + Assert.AreEqual(extent.Xmin, backExtent.Xmin, 1e-6); + Assert.AreEqual(extent.Xmax, backExtent.Xmax, 1e-6); + Assert.AreEqual(extent.Ymin, backExtent.Ymin, 1e-6); + Assert.AreEqual(extent.Ymax, backExtent.Ymax, 1e-6); + } + + [TestMethod] + public void export_dxf_mtext_round_trip_keeps_size_and_line_spacing() + { + Text text = MakeText("first line\nsecond line", 12.0); + text.LineSpacing = 1.4; + text.Alignment = Text.AlignMode.Bottom; // MTEXT has no baseline attachment + BoundingCube extent = text.GetBoundingCube(); + + string file = Export(text); + ACadSharp.Entities.MText written = ACadSharp.IO.DxfReader.Read(file).Entities.OfType().Single(); + Assert.AreEqual(OutlineHeight(MakeText("H", 12.0)), written.Height, 1e-6, "group 40 is the height of the capital letters"); + + Text back = ReimportSingle(file); + Assert.AreEqual(text.TextSize, back.TextSize, 1e-6, "TextSize"); + Assert.AreEqual(text.LineSpacing, back.LineSpacing, 1e-6, "LineSpacing"); + BoundingCube backExtent = back.GetBoundingCube(); + Assert.AreEqual(extent.Ymin, backExtent.Ymin, 1e-6); + Assert.AreEqual(extent.Ymax, backExtent.Ymax, 1e-6); + } + + // --- helpers ------------------------------------------------------------------------- + + private static Text MakeText(string value, double size) + { + Text text = Text.Construct(); + text.Font = Font; + text.TextString = value; + text.Location = new GeoPoint(5, 7, 0); + text.LineDirection = GeoVector.XAxis; + text.GlyphDirection = GeoVector.YAxis; + text.TextSize = size; + return text; + } + + /// + /// The height of the glyphs as CADability draws them. + /// + private static double OutlineHeight(Text text) + { + BoundingCube ext = BoundingCube.EmptyBoundingCube; + foreach (IGeoObject go in text.GetOutline()) ext.MinMax(go.GetBoundingCube()); + return ext.Ymax - ext.Ymin; + } + + private string Export(IGeoObject geoObject) + { + Project project = Project.CreateSimpleProject(); + project.GetModel(0).Add(geoObject.Clone()); + string file = this.TestContext.TestName + ".dxf"; + Assert.IsTrue(project.Export(file, "dxf"), "export must succeed"); + return file; + } + + private static ACadSharp.Entities.TextEntity ReadSingleText(string file) + { + return ACadSharp.IO.DxfReader.Read(file).Entities.OfType().Single(); + } + + private static Text ReimportSingle(string file) + { + Project project = Project.ReadFromFile(file, "dxf"); + Assert.IsNotNull(project); + return project.GetActiveModel().AllObjects.OfType().Single(); + } + } +}