From 95003ce83567a147d14a3b5230faa886c277b7b9 Mon Sep 17 00:00:00 2001 From: Josef Marek Date: Mon, 7 Sep 2026 12:18:14 +0200 Subject: [PATCH] =?UTF-8?q?Textov=C3=A9=20styly:=20zachovat=20jm=C3=A9na?= =?UTF-8?q?=20ze=20zdroje=20a=20opravit=20MTEXT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Šablona má styly pojmenované česky („Popis základní", „Název stavby"), export je zahazoval a vyráběl náhradní jména podle fontu (Verdana, Verdana_B, Arial_1) – pro každou kombinaci fontu a sklonu nový záznam. Ve správci stylů v CADu pak nešlo nic najít ani hromadně změnit. Jméno stylu se teď nese v `TextStyle.Nazev` až do zápisu; jeden zdrojový styl = jeden styl ve výstupu. Dál: - SHX styly (vektorové fonty AutoCADu) se přenášejí i s odkazem na soubor a příznakem `IsShape`. Kreslí se pořád náhradním fontem – SHX se jako TrueType načíst nedá –, ale v exportu zůstane původní styl, ne Standard. - MTEXT si drží natočení. Nešlo přiřadit `Rotation` (v ACadSharp je jen ke čtení), takže se nastavuje směrovým vektorem osy X; natočený víceřádkový atribut dřív vycházel vodorovně. - MTEXT si drží šířkový faktor přes kód \W. Jako vlastnost entity ho nemá. Čtečka ho zase odloupne – ACadSharp ho v PlainText nechává, takže bez toho by se „W0.8;" vykreslilo jako text. - Hodnota MTEXTu se escapuje celá (zpětné lomítko, složené závorky), ne jen zlomy řádků. Cesta typu C:\projekt v poli dřív rozbila formátování. 99 testů zelených. Co-Authored-By: Claude Opus 5 --- Rozpisky.Tests/TextStylyRoundTripTesty.cs | 102 ++++++++++++++++++++++ Rozpisky/Dxf/DxfTemplate.cs | 34 ++++++-- Rozpisky/Rendering/DxfProfileRenderer.cs | 69 +++++++++++++-- Rozpisky/Rendering/IProfileRenderer.cs | 10 ++- Rozpisky/Rendering/SkiaProfileRenderer.cs | 3 +- 5 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 Rozpisky.Tests/TextStylyRoundTripTesty.cs diff --git a/Rozpisky.Tests/TextStylyRoundTripTesty.cs b/Rozpisky.Tests/TextStylyRoundTripTesty.cs new file mode 100644 index 0000000..c0f6d2f --- /dev/null +++ b/Rozpisky.Tests/TextStylyRoundTripTesty.cs @@ -0,0 +1,102 @@ +using System.IO; +using ACadSharp.Entities; +using ACadSharp.IO; +using Rozpisky.Dxf; +using Rozpisky.Rendering; +using Xunit; + +namespace Rozpisky.Tests; + +/// +/// Textové styly a MTEXT tam a zpět. Šablona má styly pojmenované česky („Popis základní", +/// „Název stavby"); export je dřív zahodil a vyrobil náhradní jména podle fontu (Verdana_1), +/// takže ve správci stylů v CADu nešlo nic najít ani hromadně změnit. +/// +public class TextStylyRoundTripTesty : IDisposable +{ + private readonly string _soubor = Path.Combine(Path.GetTempPath(), "rozpisky-styly-" + Guid.NewGuid().ToString("N") + ".dxf"); + + public void Dispose() + { + try { File.Delete(_soubor); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private ACadSharp.CadDocument Kolecko(Action kresba) + { + var dxf = new DxfProfileRenderer(); + kresba(dxf); + DxfWriter.Write(_soubor, dxf.Document, binary: true); + return DxfReader.Read(_soubor); + } + + [Fact] + public void StylySiDrziJmenaZeZdroje() + { + var doc = Kolecko(g => new DxfTemplate(TestData.Sablona).Replay(g)); + var jmena = doc.TextStyles.Select(s => s.Name).ToList(); + + Assert.Contains("Popis základní", jmena); + Assert.Contains("Název stavby", jmena); + // Náhradní jména podle fontu nesmí vzniknout, když zdroj styl pojmenovaný má. + Assert.DoesNotContain(jmena, j => j.StartsWith("Verdana", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void JedenZdrojovyStylDaJedenStylVeVystupu() + { + // Dřív vznikal nový styl pro každou kombinaci fontu a sklonu, takže jich ve výstupu + // bylo víc než ve zdroji a texty se rozpadly mezi ně. + var zdroj = DxfReader.Read(TestData.Sablona); + var doc = Kolecko(g => new DxfTemplate(TestData.Sablona).Replay(g)); + + var pouzite = doc.Entities.OfType().Select(t => t.Style?.Name) + .Concat(doc.Entities.OfType().Select(m => m.Style?.Name)) + .Where(n => n is not null).Distinct().ToList(); + + Assert.NotEmpty(pouzite); + foreach (var n in pouzite) + Assert.True(zdroj.TextStyles.Contains(n!), $"styl {n} ve zdroji není"); + } + + [Fact] + public void MtextSiDrziNatoceni() + { + var doc = Kolecko(g => g.Text(0, 0, "dva\nřádky", 3, maxWidthMm: 40, rotationDeg: 30)); + var mt = Assert.Single(doc.Entities.OfType()); + Assert.Equal(30, mt.Rotation * 180 / Math.PI, 3); + } + + [Fact] + public void MtextSiDrziSirkovyFaktor() + { + var doc = Kolecko(g => g.Text(0, 0, "text", 3, maxWidthMm: 40, widthFactor: 0.8)); + var mt = Assert.Single(doc.Entities.OfType()); + Assert.StartsWith(@"\W0.8;", mt.Value); + + // A vlastní čtečka ho musí zase odloupnout, jinak by se „W0.8;" vykreslilo jako text. + var (cisty, wf) = DxfTemplate.MTextSirka(mt.Value, mt.PlainText); + Assert.Equal("text", cisty); + Assert.Equal(0.8, wf!.Value, 3); + } + + [Fact] + public void ZpetneLomitkoAZavorkyNerozbijiMtext() + { + // Reálná hodnota z pole: cesta nebo poznámka se zpětným lomítkem. Bez escapování + // by ji CAD přečetl jako formátovací kód a část textu by zmizela. + const string hodnota = @"C:\projekt {2025} \P"; + var doc = Kolecko(g => g.Text(0, 0, hodnota, 3, maxWidthMm: 60)); + var mt = Assert.Single(doc.Entities.OfType()); + Assert.Equal(hodnota, mt.PlainText); + } + + [Fact] + public void ZlomRadkuSePreloziNaMtextovyKod() + { + var doc = Kolecko(g => g.Text(0, 0, "první\ndruhý", 3, maxWidthMm: 60)); + var mt = Assert.Single(doc.Entities.OfType()); + Assert.Contains(@"\P", mt.Value); + Assert.Equal("první\ndruhý", mt.PlainText.Replace("\r\n", "\n")); + } +} diff --git a/Rozpisky/Dxf/DxfTemplate.cs b/Rozpisky/Dxf/DxfTemplate.cs index dceeb12..4435b49 100644 --- a/Rozpisky/Dxf/DxfTemplate.cs +++ b/Rozpisky/Dxf/DxfTemplate.cs @@ -166,8 +166,12 @@ public sealed partial class DxfTemplate case MText mt: { var (h, v) = MapMTextAlign(mt.AttachmentPoint); - g.Text(mt.InsertPoint.X, mt.InsertPoint.Y, mt.PlainText, mt.Height, h, v, mt.Rotation * RadToDeg, - StyleOf(mt.Style), 1, mt.RectangleWidth); + // MTEXT nemá šířkový faktor jako vlastnost entity – nese ho buď kód \W + // na začátku hodnoty, nebo (jinak) styl textu. + var (text, wfKod) = MTextSirka(mt.Value, mt.PlainText); + double wf = wfKod ?? (mt.Style?.Width > 0 ? mt.Style.Width : 1); + g.Text(mt.InsertPoint.X, mt.InsertPoint.Y, text, mt.Height, h, v, mt.Rotation * RadToDeg, + StyleOf(mt.Style), wf, mt.RectangleWidth); break; } } @@ -219,6 +223,25 @@ public sealed partial class DxfTemplate return true; } + /// Odloupne z MTEXTu úvodní kód šířkového faktoru \W1.2;. ACadSharp ho + /// v PlainText nechává (jen bez zpětného lomítka), takže by se jinak vykreslil jako + /// text. Vrací čistý text a faktor, když tam byl. + internal static (string Text, double? WidthFactor) MTextSirka(string? hodnota, string? plainText) + { + string plain = plainText ?? ""; + if (hodnota is null || !hodnota.StartsWith(@"\W", StringComparison.Ordinal)) return (plain, null); + int strednik = hodnota.IndexOf(';', 2); + if (strednik < 0) return (plain, null); + if (!double.TryParse(hodnota[2..strednik], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double wf) || wf <= 0) + return (plain, null); + + // Z PlainText ubyla jen zpětná lomítka, tedy „W1.2;" bez prvního znaku kódu. + string predpona = hodnota[1..(strednik + 1)]; + if (plain.StartsWith(predpona, StringComparison.Ordinal)) plain = plain[predpona.Length..]; + return (plain, wf); + } + /// Popis hladiny pro kreslítko – celý, ať se dá ve výstupu postavit znovu /// (barva z tabulky hladin, tloušťka, typ čáry, příznak tisku). private static HladinaStav HladinaZ(ACadSharp.Tables.Layer? layer) @@ -275,13 +298,14 @@ public sealed partial class DxfTemplate { double obliqueRad = entityObliqueRad != 0 ? entityObliqueRad : s?.ObliqueAngle ?? 0; if (s is null) return new(ObliqueDeg: obliqueRad * RadToDeg); - string? file = s.IsShapeFile || string.IsNullOrWhiteSpace(s.Filename) ? null : s.Filename; + string? file = string.IsNullOrWhiteSpace(s.Filename) ? null : s.Filename; + string? ttf = s.IsShapeFile ? null : file; bool bold = s.TrueType.HasFlag(FontFlags.Bold); bool italic = s.TrueType.HasFlag(FontFlags.Italic); // Rodina písma: z .ttf souboru; když styl soubor nemá (TTF zadané jen jménem), vezmi // název typu písma z XData(ACAD) – tam AutoCAD ukládá rodinu (např. „Arial Black"). - string? family = file is not null ? Path.GetFileNameWithoutExtension(file) : FontRodinaZeXData(s); - return new(family, bold, italic, file, obliqueRad * RadToDeg); + string? family = ttf is not null ? Path.GetFileNameWithoutExtension(ttf) : FontRodinaZeXData(s); + return new(family, bold, italic, file, obliqueRad * RadToDeg, s.Name, s.IsShapeFile); } /// Název rodiny TrueType písma uložený ve stylu jako XData appid „ACAD" (první ASCII diff --git a/Rozpisky/Rendering/DxfProfileRenderer.cs b/Rozpisky/Rendering/DxfProfileRenderer.cs index a655c4e..fb228d1 100644 --- a/Rozpisky/Rendering/DxfProfileRenderer.cs +++ b/Rozpisky/Rendering/DxfProfileRenderer.cs @@ -124,9 +124,11 @@ public sealed class DxfProfileRenderer : IProfileRenderer { var mtext = new MText { - Value = text.Replace("\r\n", "\\P").Replace("\n", "\\P"), + Value = MTextHodnota(text, widthFactor), InsertPoint = new XYZ(x, y, 0), Height = heightMm, + // MTEXT nemá zapisovatelné natočení – nese ho směrový vektor osy X (group 11). + AlignmentPoint = SmerOsyX(rotationDeg), RectangleWidth = maxWidthMm, AttachmentPoint = MapAttach(h, v), }; @@ -185,6 +187,40 @@ public sealed class DxfProfileRenderer : IProfileRenderer } } + /// Směrový vektor osy X textu pro dané natočení – tím se natáčí MTEXT. + private static XYZ SmerOsyX(double rotationDeg) + { + double rad = rotationDeg * Math.PI / 180.0; + return new XYZ(Math.Cos(rad), Math.Sin(rad), 0); + } + + /// Text pro MTEXT. Escapuje znaky, které jsou v MTEXTu řídicí (zpětné lomítko a + /// složené závorky) – bez toho by uživatelova hodnota se zpětným lomítkem rozbila formátování –, + /// zlomy řádků převede na \P a šířkový faktor doplní kódem \W: MTEXT ho na rozdíl + /// od TEXTu nemá jako vlastnost entity, takže jinak by se ztratil. + private static string MTextHodnota(string text, double widthFactor) + { + var sb = new System.Text.StringBuilder(); + if (widthFactor > 0 && Math.Abs(widthFactor - 1) > 1e-9) + sb.Append(@"\W") + .Append(widthFactor.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture)) + .Append(';'); + + foreach (var znak in text) + { + switch (znak) + { + case '\\': sb.Append(@"\\"); break; + case '{': sb.Append(@"\{"); break; + case '}': sb.Append(@"\}"); break; + case '\n': sb.Append(@"\P"); break; + case '\r': break; + default: sb.Append(znak); break; + } + } + return sb.ToString(); + } + /// Převede analytickou hranu obrysu na DXF hraniční edge šrafy (úsečka / kruhový oblouk). /// U oblouku: záporný rozsah (CW) se v DXF vyjádří CounterClockWise=false, kde čtečka úhly /// zrcadlí přes osu X – proto pro CW meze −A0 / −(A0+Sweep), znormalizované do [0,2π). @@ -227,20 +263,41 @@ public sealed class DxfProfileRenderer : IProfileRenderer /// stylu (použije se výchozí styl dokumentu). private AcTextStyle? StylProEntitu(TextStyle s) { - // Soubor fontu: přednostně .ttf z předlohy, jinak odvozeno z rodiny písma. + // Soubor fontu: přednostně ten z předlohy (.ttf i .shx), jinak odvozeno z rodiny písma. string? file = s.FontFile ?? (string.IsNullOrWhiteSpace(s.FontFamily) ? null : s.FontFamily + ".ttf"); - if (file is null && string.IsNullOrWhiteSpace(s.FontFamily)) return null; + if (file is null && string.IsNullOrWhiteSpace(s.FontFamily) && string.IsNullOrWhiteSpace(s.Nazev)) + return null; - string klic = $"{file}|{s.FontFamily}|{(s.Bold ? "B" : "")}{(s.Italic ? "I" : "")}|{s.ObliqueDeg}"; + // Klíčem je jméno stylu ze zdroje, pokud ho zná – jeden zdrojový styl = jeden styl ve + // výstupu. Bez toho vznikal styl pro každou kombinaci fontu a sklonu a jména se + // vymýšlela (Verdana_1, Arial_2), takže se ve správci stylů nedalo nic najít. + string klic = string.IsNullOrWhiteSpace(s.Nazev) + ? $"\0odvozeny|{file}|{s.FontFamily}|{(s.Bold ? "B" : "")}{(s.Italic ? "I" : "")}|{s.ObliqueDeg}" + : s.Nazev!; if (_styly.TryGetValue(klic, out var styl)) return styl; var ff = FontFlags.Regular; if (s.Bold) ff |= FontFlags.Bold; if (s.Italic) ff |= FontFlags.Italic; - string nazev = UnikatniNazevStylu(s.FontFamily ?? Path.GetFileNameWithoutExtension(file) ?? "Styl", s.Bold, s.Italic); + string nazev = string.IsNullOrWhiteSpace(s.Nazev) + ? UnikatniNazevStylu(s.FontFamily ?? Path.GetFileNameWithoutExtension(file) ?? "Styl", s.Bold, s.Italic) + : s.Nazev!; + + if (_doc.TextStyles.TryGetValue(nazev, out var existujici)) + { + _styly[klic] = existujici; + return existujici; + } + // Oblique i na stylu – TEXT ho nese sám (group 51), ale MTEXT sklon přebírá jen ze stylu. - styl = new AcTextStyle(nazev) { Filename = file ?? "", TrueType = ff, ObliqueAngle = s.ObliqueDeg * Math.PI / 180.0 }; + styl = new AcTextStyle(nazev) + { + Filename = file ?? "", + TrueType = ff, + ObliqueAngle = s.ObliqueDeg * Math.PI / 180.0, + Flags = s.JeShx ? ACadSharp.Tables.StyleFlags.IsShape : ACadSharp.Tables.StyleFlags.None, + }; _doc.TextStyles.Add(styl); _styly[klic] = styl; return styl; diff --git a/Rozpisky/Rendering/IProfileRenderer.cs b/Rozpisky/Rendering/IProfileRenderer.cs index c5cd576..6994fec 100644 --- a/Rozpisky/Rendering/IProfileRenderer.cs +++ b/Rozpisky/Rendering/IProfileRenderer.cs @@ -8,12 +8,18 @@ public enum VAlign { Baseline, Bottom, Middle, Top } /// /// Vzhled písma přenesený z DXF textového stylu (ROZPISKY.md §3): rodina fontu + řez, -/// volitelně přímo soubor fontu (.ttf) pro věrný řez. default = fallback font rendereru. +/// volitelně přímo soubor fontu pro věrný řez. default = fallback font rendereru. /// = sklon znaků ve stupních (DXF oblique angle, group 51 entity / /// group 50 stylu); kladný = naklonění doprava, 0 = vzpřímeně. +/// = jméno stylu ve zdrojovém výkresu („Popis základní", +/// „Název stavby"). Nese se dál, aby ho DXF export zapsal beze změny – dřív se zahazovalo +/// a v exportu vznikala náhradní jména typu Verdana_1. = styl +/// odkazuje na vektorový font AutoCADu (.shx); ten se nedá načíst jako TrueType, takže se +/// kreslí náhradním fontem, ale do DXF se zapíše původní. /// public readonly record struct TextStyle(string? FontFamily = null, bool Bold = false, bool Italic = false, - string? FontFile = null, double ObliqueDeg = 0); + string? FontFile = null, double ObliqueDeg = 0, + string? Nazev = null, bool JeShx = false); /// Druh hrany obrysu: úsečka nebo kruhový oblouk. Elipsy a spliny se do obrysu vkládají /// jako jemná řada úseček (viz ), takže renderery vystačí se dvěma případy. diff --git a/Rozpisky/Rendering/SkiaProfileRenderer.cs b/Rozpisky/Rendering/SkiaProfileRenderer.cs index 87df9d0..d33f52e 100644 --- a/Rozpisky/Rendering/SkiaProfileRenderer.cs +++ b/Rozpisky/Rendering/SkiaProfileRenderer.cs @@ -66,7 +66,8 @@ public sealed class SkiaProfileRenderer : IProfileRenderer, IDisposable private SKTypeface Typeface(TextStyle style) { // 1) Přesný font ze souboru, na který odkazuje DXF styl (.ttf v systémové složce Fonts). - if (!string.IsNullOrWhiteSpace(style.FontFile)) + // SHX (vektorový font AutoCADu) se takhle načíst nedá – jde rovnou na náhradu. + if (!style.JeShx && !string.IsNullOrWhiteSpace(style.FontFile)) { var fileKey = ("\0file:" + style.FontFile, 0, false); if (_typefaces.TryGetValue(fileKey, out var tff)) return tff;