#nullable enable using System.Drawing; using System.Drawing.Drawing2D; using Lib.Utils; using Svg; using Svg.Pathing; using Svg.Transforms; using T3.Core.Utils; // ReSharper disable MemberCanBePrivate.Global // ReSharper disable TooWideLocalVariableScope // ReSharper disable CheckNamespace namespace Lib.point.io; /** * This operator is a compromise: Please also review the additional documentation * on the wiki: https://github.com/tixl3d/tixl/wiki/SvgLineFonts */ [Guid("3d862455-6a7b-4bf6-a159-e4f7cdba6062")] internal sealed class LineTextPoints : Instance { [Output(Guid = "618e9151-cd91-4aa6-9d91-4bb51610cc8b")] public readonly Slot ResultList = new(); [Output(Guid = "15140635-C51D-4674-8FCA-006EBA46496E")] public readonly Slot StartIndicesAndCounts = new(); public LineTextPoints() { _svgResource = new Resource(FilePath, SvgLoader.TryLoad); _svgResource.AddDependentSlots(ResultList); ResultList.UpdateAction += Update; _structuredPoints.TypedElements[_structuredPoints.NumElements - 1] = Point.Separator(); } private void Update(EvaluationContext context) { if (!_svgResource.TryGetValue(context, out var svgDoc)) { ResultList.Value = null; return; } var lineFontKey = new LineFontDefinition(Key: FilePath.GetValue(context) ?? string.Empty, ReduceCurveThreshold: ReduceCurveThreshold.GetValue(context), CornerBalance: CornerWeightBalance.GetValue(context)); var fontNeedsUpdate = _lineFontKey != lineFontKey; var needsUpdate = fontNeedsUpdate || Text.DirtyFlag.IsDirty || Position.DirtyFlag.IsDirty || Size.DirtyFlag.IsDirty || LineHeight.DirtyFlag.IsDirty || VerticalAlign.DirtyFlag.IsDirty || HorizontalAlign.DirtyFlag.IsDirty || Spacing.DirtyFlag.IsDirty; if (!needsUpdate) return; _lineFont = LineFont.Create(svgDoc, _lineFontKey, lineFontKey, this); _lineFontKey = lineFontKey; var cursorPos = Vector3.Zero; var text = Text.GetValue(context); if (string.IsNullOrEmpty(text)) text = " "; var horizontalAlign = HorizontalAlign.GetEnumValue(context); var verticalAlign = VerticalAlign.GetEnumValue(context); var characterSpacing = Spacing.GetValue(context); var lineHeightFactor = LineHeight.GetValue(context); var verticalFlip = _lineFont.UnitsPerEm < 0 ? 1 : -1; var cursorLineHeight = MathF.Abs(_lineFont.LineHeight) * verticalFlip * lineHeightFactor; var color = Color.GetValue(context); var lineWidth = LineWidth.GetValue(context); var position = Position.GetValue(context); var entitySetter = SetFx1To.GetEnumValue(context); if (_lineFont == null) return; float cursorX = 0; float cursorY = 0; var needComputeLineWidths = verticalAlign != VerticalAligns.Top || horizontalAlign != HorizontalAligns.Left; var lineCountInText = 1; if (verticalAlign == VerticalAligns.Middle || verticalAlign == VerticalAligns.Bottom) { foreach (var c in text) { if (c == '\n') { lineCountInText++; } } } switch (verticalAlign) { case VerticalAligns.Top: cursorY -= _lineFont.LineHeight * verticalFlip; break; case VerticalAligns.Middle: cursorY -= _lineFont.LineHeight * verticalFlip - (lineCountInText - 1) / 2f * cursorLineHeight; break; case VerticalAligns.Bottom: cursorY -= _lineFont.LineHeight * verticalFlip - (lineCountInText - 1) * cursorLineHeight; break; } _points.Clear(); _startIndicesAndCounts.Clear(); var lastCharForKerning = 0; var size = Size.GetValue(context) / MathF.Abs(_lineFont.UnitsPerEm) * 2/1080f; // Scaling to match 1080p 72DPI pt font sizes var horizontalLineOffset = 0f; var needUpdateCurrentLineWidth = needComputeLineWidths; var wordIndex = 0; var lineIndex = 0; for (var index = 0; index < text.Length; index++) { if (needUpdateCurrentLineWidth) { horizontalLineOffset = GetHorizontalLineOffset(text, index, horizontalAlign, characterSpacing); needUpdateCurrentLineWidth = false; } var c = text[index]; if (c == '\n') { cursorY -= cursorLineHeight; cursorX = 0; lastCharForKerning = 0; needUpdateCurrentLineWidth = true; wordIndex++; lineIndex++; continue; } if (c == ' ') { wordIndex++; } var glyph = _lineFont?.GetGlyphForCharacter(c); if (glyph == null) { lastCharForKerning = 0; continue; } if (lastCharForKerning != 0) { int key = lastCharForKerning | c; if (_lineFont!.KerningForPairs.TryGetValue(key, out var kerning2)) { cursorX += kerning2; } } var numPoints = glyph.Points.Length; var entityFloat = entitySetter switch { Entities.None => 0, Entities.Characters => index, Entities.Words => wordIndex, Entities.Lines => lineIndex, _ => throw new ArgumentOutOfRangeException() }; _startIndicesAndCounts.Add(new Int2( _points.Count, numPoints)); if (numPoints > 0) { for (var pointIndex = 0; pointIndex < numPoints; pointIndex++) { var p = glyph.Points[pointIndex]; var pointPos = p.Position; p.Position = new Vector3( (cursorX - glyph.VertOriginX + pointPos.X + horizontalLineOffset) * size, (cursorY - glyph.VertOriginY + pointPos.Y) * size * verticalFlip, 0) + position; p.Color = color; p.Scale *= lineWidth;// keep NaN p.F1 = entityFloat; p.F2 = 1; _points.Add(p); } } cursorPos.X += glyph.AdvanceX; cursorX += glyph.AdvanceX; cursorX += characterSpacing * MathF.Abs(_lineFont?.UnitsPerEm ?? 0) * 0.01f; lastCharForKerning = c; } var pointCount = _points.Count; if (pointCount == 0) { ResultList.Value = null; return; } // Copy to points if (_structuredPoints.TypedElements.Length != pointCount) _structuredPoints = new StructuredList(_points.Count); for (int index = pointCount - 1; index >= 0; --index) { _structuredPoints.TypedElements[index] = _points[index]; } // Copy indices if (_structuredIndicesAndCounts.TypedElements.Length != _startIndicesAndCounts.Count) _structuredIndicesAndCounts = new StructuredList(_startIndicesAndCounts.Count); for (int index = _startIndicesAndCounts.Count - 1; index >= 0; --index) { _structuredIndicesAndCounts.TypedElements[index] = _startIndicesAndCounts[index]; } ResultList.Value = _structuredPoints; StartIndicesAndCounts.Value = _structuredIndicesAndCounts; } private float GetHorizontalLineOffset(string text, int indexAtLineStart, HorizontalAligns horizontalAligns, float characterSpacing) { if (_lineFont == null) return 0; var widthSum = 0f; for (var index = indexAtLineStart; index < text.Length; index++) { var c = text[index]; if (c == '\n') { break; } var glyph = _lineFont?.GetGlyphForCharacter(c); if (glyph == null) continue; widthSum += glyph.AdvanceX + characterSpacing * MathF.Abs(_lineFont!.UnitsPerEm) * 0.01f; } if (horizontalAligns == HorizontalAligns.Center) return -widthSum / 2; if (horizontalAligns == HorizontalAligns.Right) return -widthSum; return 0; } private readonly List _points = new(1000); private StructuredList _structuredPoints = new(1); private readonly List _startIndicesAndCounts = new(128); private StructuredList _structuredIndicesAndCounts = new(1); public enum HorizontalAligns { Left, Center, Right, } public enum VerticalAligns { Top, Middle, Bottom, } [Input(Guid = "8EE43C95-869E-4390-A567-CB0BB9C31BDD")] public readonly InputSlot Text = new(); [Input(Guid = "1077857C-93FA-403A-BBD1-FD5B2B402AE6")] public readonly InputSlot Position = new(); [Input(Guid = "FB14EABB-B3C8-437C-B083-EBC5E0A46F7B")] public readonly InputSlot Size = new(); [Input(Guid = "EB22073E-2348-4E76-A096-A4F6F80E2C01")] public readonly InputSlot LineHeight = new(); [Input(Guid = "EEF1217E-E719-4C32-9A8A-5E7ACBE1A185", MappedType = typeof(VerticalAligns))] public readonly InputSlot VerticalAlign = new(); [Input(Guid = "4C275222-D85C-4037-8161-536105A6C80F", MappedType = typeof(HorizontalAligns))] public readonly InputSlot HorizontalAlign = new(); [Input(Guid = "D9CD13D4-C6A3-42DE-9119-5A164CB12C41")] public readonly InputSlot Spacing = new(); [Input(Guid = "05ACBA8D-1DD1-4D6C-BEFF-E11E466F7253")] public readonly InputSlot FilePath = new(); [Input(Guid = "77B18D33-A41E-4676-A55C-0DF8E0290D05")] public readonly InputSlot ReduceCurveThreshold = new(); [Input(Guid = "96F345D9-2E38-4AF6-937D-EE14528727E4")] public readonly InputSlot CornerWeightBalance = new(); [Input(Guid = "D0B12D48-75A6-4DC5-A634-4D969EC20A29")] public readonly InputSlot Color = new(); [Input(Guid = "72324D06-1A8E-44F1-A946-9B0E7987908E")] public readonly InputSlot LineWidth = new(); [Input(Guid = "585A1124-1505-49BB-8E42-9F3E9B51845C", MappedType = typeof(Entities))] public readonly InputSlot SetFx1To = new(); private enum Entities { None, Characters, Words, Lines, } private LineFont? _lineFont; private readonly Resource _svgResource; private LineFontDefinition? _lineFontKey; } internal record struct LineFontDefinition(string Key, float ReduceCurveThreshold, float CornerBalance); internal sealed class LineFont { private const int GlyphTableLength = 256; private Glyph[] _glyphTable = []; // Lookup table for fast lookup private readonly Dictionary _glyphsForCharacters = new(); // Fallback dictionary public Glyph? GetGlyphForCharacter(char c) { var value = (int)c; if (value < GlyphTableLength) return _glyphTable[value]; Log.Debug($"need dictionary for {c}", this); return _glyphsForCharacters.TryGetValue(c, out var glyph) ? glyph : null; } public void InitGlyphTable() { _glyphTable = new Glyph[GlyphTableLength]; foreach (var (c, glyph) in _glyphsForCharacters) { var value = (int)c; if (c >= GlyphTableLength) continue; _glyphTable[value] = glyph; } } // ReSharper disable once CollectionNeverUpdated.Global public readonly Dictionary KerningForPairs = new(); public float UnitsPerEm = 1000; public float Ascent = 800; public float Descent = -200; public float CapHeight = 500; public float XHeight = 300; public float LineHeight = 1000; // identical with FontSize public float ReduceCurveThreshold = 0.2f; public float CornerWeightBalance = 0.5f; public static LineFont Create(SvgDocument svgDoc, LineFontDefinition? previousKey, LineFontDefinition key, object user) { if (svgDoc == null) throw new ArgumentNullException(nameof(svgDoc)); if (previousKey == key) { return _definitionsForFilePaths[previousKey.Value]; } if (previousKey.HasValue && _users.TryGetValue(previousKey.Value, out var previousUsers)) { previousUsers.Remove(user); if (previousUsers.Count == 0) { _users.Remove(previousKey.Value); _definitionsForFilePaths.Remove(previousKey.Value); } } if (_definitionsForFilePaths.TryGetValue(key, out var definition)) { if (_users.TryGetValue(key, out var users)) { users.Add(user); } else { _users[key] = [user]; } return definition; } var svgElementCollection = svgDoc.Children; var newLineFont = new LineFont() { ReduceCurveThreshold = key.ReduceCurveThreshold, CornerWeightBalance = key.CornerBalance, }; ParseFontDefinitionsInElements(svgElementCollection, newLineFont); newLineFont.InitGlyphTable(); _definitionsForFilePaths[key] = newLineFont; return newLineFont; } private static void ParseFontDefinitionsInElements(SvgElementCollection svgElementCollection, LineFont font) { foreach (var svgDocChild in svgElementCollection) { switch (svgDocChild) { case SvgFont svgFont: CollectGlyphsFromSvgFont(svgFont, font); break; case SvgDefinitionList svgDefinitionList: ParseFontDefinitionsInElements(svgDefinitionList.Children, font); break; } } } private static void CollectGlyphsFromSvgFont(SvgFont svgFont, LineFont font) { var fontFace = svgFont.Children.SingleOrDefault(c => c is SvgFontFace); if (fontFace is SvgFontFace svgFontFace) { font.Descent = svgFontFace.Descent; font.Ascent = svgFontFace.Ascent; font.XHeight = svgFontFace.XHeight; font.UnitsPerEm = svgFontFace.UnitsPerEm; } font.LineHeight = svgFont.FontSize != 0 ? svgFont.FontSize : font.UnitsPerEm; foreach (var svgFontChild in svgFont.Children) { if (svgFontChild is not SvgGlyph svgGlyph) continue; var points = GetPointsFromSvgGroup(font, svgGlyph); // if (points.Length == 0) // continue; if (string.IsNullOrEmpty(svgGlyph.Unicode) || svgGlyph.Unicode.Length != 1) { Log.Warning("Skipping svg glyph with missing or invalid unicode:" + svgGlyph.GlyphName); continue; } var uniCode = svgGlyph.Unicode[0]; var glyphValue = (int)uniCode; // There are some exporters that automatically assign a "icon" range to the Id. // If that's the case we fall back to the GlyphName. if (glyphValue > 1000 && svgGlyph.GlyphName?.Length == 1) { uniCode = svgGlyph.GlyphName[0]; } font._glyphsForCharacters[uniCode] = new Glyph { Points = points, UniCode = "" + uniCode, Name = svgGlyph.GlyphName!, AdvanceX = svgGlyph.HorizAdvX, VertOriginX = svgGlyph.VertOriginX, VertOriginY = svgGlyph.VertOriginY, BoundsMin = new Vector2(svgGlyph.Bounds.Left, svgGlyph.Bounds.Top), BoundsMax = new Vector2(svgGlyph.Bounds.Right, svgGlyph.Bounds.Bottom), }; } } private static Point[] GetPointsFromSvgGroup(LineFont font, SvgElement svgGlyph) { var svgElements = new List { svgGlyph }; var pathElements = GraphicsPathEntry.CreateFromSvgElements(svgElements, true); // Flatten and sum total point count including separators _tempPoints.Clear(); foreach (var pathElement in pathElements) { try { pathElement.GraphicsPath?.Flatten(null, font.ReduceCurveThreshold); _ = pathElement.GraphicsPath?.PathPoints.Length; // Access path points to see if result is valid. } catch (Exception e) { Log.Debug("Can't flatten element" + e.Message); continue; } var path = pathElement.GraphicsPath; if (path == null) continue; var pathPointCount = path.PathPoints.Length; var loopStartIndex = _tempPoints.Count; var lastPos = new Vector3(-9999f, 0f, 0f); for (var pathPointIndex = 0; pathPointIndex < pathPointCount; pathPointIndex++) { var point = path.PathPoints[pathPointIndex]; var position = (new Vector3(point.X, 1 - point.Y, 0)); var length = (lastPos - position).LengthSquared(); lastPos = position; var tooClose = length < 0.000001f; if (tooClose) { continue; } _tempPoints.Add(new Point { Position = position, F1 = 1, Orientation = Quaternion.Identity, Color = Vector4.One, Scale = Vector3.One, F2 = 1, }); } // Close loop? if (pathElement.NeedsClosing) { _tempPoints.Add(_tempPoints[loopStartIndex]); } _tempPoints.Add(Point.Separator()); } var points = _tempPoints.ToArray(); var flip = font.UnitsPerEm < 0 ? 1 : -1; // Calc point orientation { var p0 = Vector3.Zero; var p0Valid = false; var p1 = Vector3.Zero; var p1Valid = false; var p2 = Vector3.Zero; var p2Valid = false; for (var pointIndex = 0; pointIndex <= points.Length + 2; pointIndex++) { p0Valid = pointIndex >= 0 && pointIndex < points.Length && !float.IsNaN(points[pointIndex].F1); if (p0Valid) p0 = points[pointIndex].Position; if (p1Valid) { if (p0Valid && p2Valid) { var v1 = Vector3.Normalize(p0-p1) - Vector3.Normalize(p2-p1); var v2 = Vector3.Normalize(p0 - p2); var v = Vector3.Lerp(v1, v2, font.CornerWeightBalance); var angle = -MathF.Atan2(v.X, v.Y); points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip); } else if (p2Valid) { var v = p1 - p2; var p =points[pointIndex-1]; var angle = -MathF.Atan2(v.X, v.Y) ; points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip ); } else if (p0Valid) { var v = p0 - p1; var p =points[pointIndex-1]; var angle = -MathF.Atan2(v.X, v.Y) ; points[pointIndex-1].Orientation= Quaternion.CreateFromAxisAngle(Vector3.UnitZ, angle * flip ); } } p2Valid = p1Valid; p2 = p1; p1Valid = p0Valid; p1 = p0; } } return points; } private static readonly Dictionary _definitionsForFilePaths = new(); private static readonly Dictionary> _users = new(); private static readonly List _tempPoints = new(100); // Reuse to avoid GC internal sealed class Glyph { //public char Char; public float AdvanceX = 10; public Point[] Points = []; public string UniCode = string.Empty; public string Name = string.Empty; public float VertOriginX; public float VertOriginY; public Vector2 BoundsMin; public Vector2 BoundsMax; } } internal sealed class GraphicsPathEntry { public GraphicsPath? GraphicsPath; public bool NeedsClosing; public static List CreateFromSvgElements(IEnumerable nodes, bool importAsLines) { var paths = new List(); _svgRenderer ??= SvgRenderer.FromImage(new Bitmap(1, 1)); foreach (var node in nodes) { switch (node) { case SvgPath svgPath: { var fullPath = svgPath.Path(_svgRenderer); SplitGraphicsPathIntoSubPaths(fullPath, paths); break; } case SvgGlyph svgGlyph: { var fullPath = svgGlyph.Path(_svgRenderer); SplitGraphicsPathIntoSubPaths(fullPath, paths); break; } case SvgGroup: break; case SvgPathBasedElement element: { if (element is SvgRectangle { Transforms: not null } rect) { foreach (var t in rect.Transforms) { if (t is not SvgTranslate tr) continue; rect.X += tr.X; rect.Y += tr.Y; } } var needsClosing = element is SvgRectangle or SvgCircle or SvgEllipse; var graphicsPath = element.Path(_svgRenderer); paths.Add(new GraphicsPathEntry { GraphicsPath = graphicsPath, NeedsClosing = needsClosing && importAsLines }); break; } } } return paths; } /// /// Splits a GraphicsPath into sub-paths at each StartPoint marker. /// This avoids manual segment iteration with AddToPath, letting the SVG library /// handle path construction internally via SvgPath.Path(renderer). /// private static void SplitGraphicsPathIntoSubPaths(GraphicsPath? fullPath, List paths) { if (fullPath == null || fullPath.PointCount == 0) return; var points = fullPath.PathPoints; var types = fullPath.PathTypes; GraphicsPath? currentSubPath = null; var subPathStart = PointF.Empty; for (var i = 0; i < points.Length; i++) { var pathType = types[i]; var pointType = (PathPointType)(pathType & 0x07); var isStartPoint = pointType == PathPointType.Start; var isCloseSubPath = (pathType & (byte)PathPointType.CloseSubpath) != 0; if (isStartPoint) { if (currentSubPath != null && currentSubPath.PointCount > 0) { paths.Add(new GraphicsPathEntry { GraphicsPath = currentSubPath, NeedsClosing = false }); } currentSubPath = new GraphicsPath(); subPathStart = points[i]; } currentSubPath ??= new GraphicsPath(); if (pointType == PathPointType.Line) { var prev = i > 0 ? points[i - 1] : subPathStart; currentSubPath.AddLine(prev, points[i]); } else if (pointType == PathPointType.Bezier3 && i + 2 < points.Length) { var prev = i > 0 ? points[i - 1] : subPathStart; currentSubPath.AddBezier(prev, points[i], points[i + 1], points[i + 2]); i += 2; } if (isCloseSubPath && currentSubPath.PointCount > 0) { currentSubPath.CloseFigure(); paths.Add(new GraphicsPathEntry { GraphicsPath = currentSubPath, NeedsClosing = false }); currentSubPath = null; } } if (currentSubPath != null && currentSubPath.PointCount > 0) { paths.Add(new GraphicsPathEntry { GraphicsPath = currentSubPath, NeedsClosing = false }); } } private static ISvgRenderer? _svgRenderer; }