diff --git a/ConsoleClient/ConsoleClient.cs b/ConsoleClient/ConsoleClient.cs new file mode 100644 index 00000000..f8079878 --- /dev/null +++ b/ConsoleClient/ConsoleClient.cs @@ -0,0 +1,16 @@ +using CommandLine; +using ConsoleClient.Settings; +using TagCloud; +using TagCloud.Client; + +namespace ConsoleClient; + +public class ConsoleClient(IApp app) : IClient +{ + public void Run() + { + Parser.Default.ParseArguments(Environment.GetCommandLineArgs()) + .WithParsed(settings => app.Run(settings.GetAppSettings(), settings.GetImageSettings())) + .WithNotParsed(_ => throw new ArgumentException("Invalid command line arguments")); + } +} \ No newline at end of file diff --git a/ConsoleClient/ConsoleClient.csproj b/ConsoleClient/ConsoleClient.csproj new file mode 100644 index 00000000..aea59c26 --- /dev/null +++ b/ConsoleClient/ConsoleClient.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/ConsoleClient/Program.cs b/ConsoleClient/Program.cs new file mode 100644 index 00000000..8a26a9b9 --- /dev/null +++ b/ConsoleClient/Program.cs @@ -0,0 +1,19 @@ +using Autofac; +using TagCloud; +using TagCloud.Client; + +namespace ConsoleClient; + +class Program +{ + static void Main() + { + var builder = new ContainerBuilder(); + builder.RegisterModule(new TagCloudModule()); + builder.RegisterType().As(); + var container = builder.Build(); + var app = container.Resolve(); + + app.Run(); + } +} \ No newline at end of file diff --git a/ConsoleClient/Settings/ConsoleSettings.cs b/ConsoleClient/Settings/ConsoleSettings.cs new file mode 100644 index 00000000..16ef165c --- /dev/null +++ b/ConsoleClient/Settings/ConsoleSettings.cs @@ -0,0 +1,53 @@ +using CommandLine; +using TagCloud.Settings; + +namespace ConsoleClient.Settings; + +public class ConsoleSettings +{ + [Option('w', "wight", Default = 800, HelpText = "Ширина изображения.")] + public int Width { get; set; } + + [Option('h', "height", Default = 800, HelpText = "Высота изображения.")] + public int Height { get; set; } + + [Option('b', "background", Default = "white", HelpText = "Цвет фона.")] + public string BackgroundColor { get; set; } = string.Empty; + + [Option('f', "font", Default = "arial", HelpText = "Шрифт.")] + public string FontFamily { get; set; } = string.Empty; + + [Option('p', "path", Default = "words.txt", HelpText = "Путь до источника слов.")] + public string SourcePath { get; set; } = string.Empty; + + [Option('o', "output", Default = "output.png", HelpText = "Путь сохранения результата.")] + public string SavePath { get; set; } = string.Empty; + + [Option("boringWords", HelpText = "Путь до списка скучный слов.")] + public string BoringWordsPath { get; set; } = string.Empty; + + [Option("minFont", Default = 15, HelpText = "Минимальный шрифт.")] + public int FontSizeMin { get; set; } + + [Option("maxFont", Default = 40, HelpText = "Максимальный шрифт.")] + public int FontSizeMax { get; set; } + + public AppSettings GetAppSettings() => + new() + { + SourcePath = SourcePath, + SavePath = SavePath, + BoringWordsPath = BoringWordsPath + }; + + public ImageSettings GetImageSettings() => + new() + { + Width = Width, + Height = Height, + BackgroundColor = BackgroundColor, + FontFamily = FontFamily, + FontSizeMax = FontSizeMax, + FontSizeMin = FontSizeMin + }; +} \ No newline at end of file diff --git a/GuiClient/Forms/MainForm.cs b/GuiClient/Forms/MainForm.cs new file mode 100644 index 00000000..25e9cd1a --- /dev/null +++ b/GuiClient/Forms/MainForm.cs @@ -0,0 +1,201 @@ +using Autofac; +using TagCloud.Settings; + +namespace GuiClient.Forms; + +public class MainForm : Form +{ + private readonly ILifetimeScope _lifetimeScope; + + private NumericUpDown? _widthInput; + private NumericUpDown? _heightInput; + private Button? _colorButton; + private TextBox? _sourceFilePathInput; + private TextBox? _boringWordsPathFileInput; + private Button? _browseSourceFileButton; + private Button? _browseBoringWordsPathFileButton; + private Button? _fontButton; + private NumericUpDown? _minFontSizeInput; + private NumericUpDown? _maxFontSizeInput; + private Button? _generateButton; + private Color _selectedColor = Color.White; + private Font _selectedFont = new("Arial", 10); + + private const int LabelX = 20; + private const int InputX = 130; + private string _sourceFilePath = string.Empty; + private string _boringWordsFilePath = string.Empty; + + public MainForm(ILifetimeScope lifetimeScope) + { + _lifetimeScope = lifetimeScope; + InitForm(); + AddWidthHeightControl(); + AddColorControl(); + AddSourceFilePathSelector(); + AddBoringWordsFilePathSelector(); + AddFontSelector(); + AddMinMaxFontSizeControls(); + AddGenerateButton(); + } + + private void InitForm() + { + Text = "Tag Cloud"; + Size = new Size(480, 420); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; + } + + private void AddWidthHeightControl() + { + var widthLabel = new Label { Text = "Ширина:", Location = new Point(LabelX, 20), AutoSize = true }; + _widthInput = new NumericUpDown + { Location = new Point(InputX, 20), Minimum = 100, Maximum = 1920, Value = 400 }; + + var heightLabel = new Label { Text = "Высота:", Location = new Point(LabelX, 60), AutoSize = true }; + _heightInput = new NumericUpDown + { Location = new Point(InputX, 60), Minimum = 100, Maximum = 1080, Value = 300 }; + + Controls.Add(widthLabel); + Controls.Add(_widthInput); + Controls.Add(heightLabel); + Controls.Add(_heightInput); + } + + private void AddColorControl() + { + var colorLabel = new Label { Text = "Цвет:", Location = new Point(LabelX, 100), AutoSize = true }; + _colorButton = new Button { Text = "Выбрать цвет", Location = new Point(250, 100) }; + var colorPicture = new PictureBox + { + Location = new Point(InputX, 100), + Width = 100, + Height = 20, + BackColor = _selectedColor + }; + + _colorButton.Click += (_, _) => + { + using var colorDialog = new ColorDialog(); + if (colorDialog.ShowDialog() == DialogResult.OK) + { + _selectedColor = colorDialog.Color; + colorPicture.BackColor = colorDialog.Color; + } + }; + + Controls.Add(colorLabel); + Controls.Add(_colorButton); + Controls.Add(colorPicture); + } + + private void AddSourceFilePathSelector() + { + var sourceFilePathLabel = new Label { Text = "Источник:", Location = new Point(LabelX, 140), AutoSize = true }; + _sourceFilePathInput = new TextBox { Location = new Point(InputX, 140), Width = 200 }; + _browseSourceFileButton = new Button { Text = "Обзор...", Location = new Point(360, 140) }; + _browseSourceFileButton.Click += (_, _) => + { + using var openFileDialog = new OpenFileDialog(); + openFileDialog.Filter = "Text Files (*.txt)|*.txt;"; + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + _sourceFilePathInput.Text = openFileDialog.FileName; + _sourceFilePath = openFileDialog.FileName; + } + }; + + Controls.Add(sourceFilePathLabel); + Controls.Add(_sourceFilePathInput); + Controls.Add(_browseSourceFileButton); + } + + private void AddBoringWordsFilePathSelector() + { + var additionalFilePathLabel = new Label + { Text = "Скучные слова:", Location = new Point(LabelX, 180), AutoSize = true }; + _boringWordsPathFileInput = new TextBox { Location = new Point(InputX, 180), Width = 200 }; + _browseBoringWordsPathFileButton = new Button { Text = "Обзор...", Location = new Point(360, 180) }; + _browseBoringWordsPathFileButton.Click += (_, _) => + { + using var openFileDialog = new OpenFileDialog(); + openFileDialog.Filter = "Text Files (*.txt)|*.txt"; + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + _boringWordsPathFileInput.Text = openFileDialog.FileName; + _boringWordsFilePath = openFileDialog.FileName; + } + }; + Controls.Add(additionalFilePathLabel); + Controls.Add(_boringWordsPathFileInput); + Controls.Add(_browseBoringWordsPathFileButton); + } + + private void AddFontSelector() + { + var fontLabel = new Label { Text = "Шрифт:", Location = new Point(LabelX, 220), AutoSize = true }; + _fontButton = new Button { Text = "Выбрать шрифт", Location = new Point(InputX, 220) }; + _fontButton.Click += (_, _) => + { + using var fontDialog = new FontDialog(); + if (fontDialog.ShowDialog() == DialogResult.OK) + { + _selectedFont = fontDialog.Font; + } + }; + + Controls.Add(fontLabel); + Controls.Add(_fontButton); + } + + private void AddMinMaxFontSizeControls() + { + var minFontSizeLabel = new Label { Text = "Мин. шрифт:", Location = new Point(LabelX, 260), AutoSize = true }; + _minFontSizeInput = new NumericUpDown + { Location = new Point(InputX, 260), Minimum = 8, Maximum = 72, Value = 8 }; + + var maxFontSizeLabel = new Label { Text = "Макс. шрифт:", Location = new Point(LabelX, 300), AutoSize = true }; + _maxFontSizeInput = new NumericUpDown + { Location = new Point(InputX, 300), Minimum = 8, Maximum = 72, Value = 24 }; + + Controls.Add(minFontSizeLabel); + Controls.Add(_minFontSizeInput); + Controls.Add(maxFontSizeLabel); + Controls.Add(_maxFontSizeInput); + } + + private void AddGenerateButton() + { + _generateButton = new Button { Text = "Сгенерировать", Location = new Point(200, 340), AutoSize = true }; + _generateButton.Click += GenerateButton_Click!; + + Controls.Add(_generateButton); + } + + private void GenerateButton_Click(object sender, EventArgs e) + { + var imageSettings = new ImageSettings + { + Width = (int)_widthInput!.Value, + Height = (int)_heightInput!.Value, + BackgroundColor = _selectedColor.Name, + FontFamily = _selectedFont.FontFamily.Name, + FontSizeMax = (int)_minFontSizeInput!.Value, + FontSizeMin = (int)_maxFontSizeInput!.Value + }; + + var appSettings = new AppSettings + { + SourcePath = _sourceFilePath, + BoringWordsPath = _boringWordsFilePath, + SavePath = "output.png" + }; + + var resultForm = new ResultForm(this, _lifetimeScope, appSettings, imageSettings); + resultForm.Show(); + Hide(); + } +} \ No newline at end of file diff --git a/GuiClient/Forms/ResultForm.cs b/GuiClient/Forms/ResultForm.cs new file mode 100644 index 00000000..a1d5df33 --- /dev/null +++ b/GuiClient/Forms/ResultForm.cs @@ -0,0 +1,47 @@ +using Autofac; +using TagCloud; +using TagCloud.Settings; + +namespace GuiClient.Forms; + +public class ResultForm : Form +{ + private readonly ILifetimeScope _lifetimeScope; + + public ResultForm(Form mainForm, ILifetimeScope lifetimeScope, AppSettings appSettings, ImageSettings imageSettings) + { + _lifetimeScope = lifetimeScope; + + using var scope = _lifetimeScope.BeginLifetimeScope(); + + Text = "Результат"; + Size = new Size(imageSettings.Width, imageSettings.Height + 70); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; + + var regenerateButton = new Button { Text = "Сгенерировать заново", Dock = DockStyle.Bottom, }; + regenerateButton.Click += (_, _) => + { + mainForm.Show(); + Close(); + }; + + ShowCloudImage(appSettings, imageSettings); + Controls.Add(regenerateButton); + } + + private void ShowCloudImage(AppSettings appSettings, ImageSettings imageSettings) + { + _lifetimeScope.Resolve().Run(appSettings, imageSettings); + var picture = new PictureBox + { + SizeMode = PictureBoxSizeMode.AutoSize, + ImageLocation = appSettings.SavePath, + Dock = DockStyle.Top, + }; + picture.Load(); + Controls.Add(picture); + } +} \ No newline at end of file diff --git a/GuiClient/GuiClient.cs b/GuiClient/GuiClient.cs new file mode 100644 index 00000000..652f88b8 --- /dev/null +++ b/GuiClient/GuiClient.cs @@ -0,0 +1,17 @@ +using Autofac; +using GuiClient.Forms; +using TagCloud.Client; + +namespace GuiClient; + +public class GuiClient(ILifetimeScope lifetimeScope) : IClient +{ + public void Run() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + var mainForm = lifetimeScope.Resolve(); + Application.Run(mainForm); + } +} \ No newline at end of file diff --git a/GuiClient/GuiClient.csproj b/GuiClient/GuiClient.csproj new file mode 100644 index 00000000..d0efe09f --- /dev/null +++ b/GuiClient/GuiClient.csproj @@ -0,0 +1,19 @@ + + + + WinExe + net8.0-windows + enable + true + enable + + + + + + + + + + + \ No newline at end of file diff --git a/GuiClient/Program.cs b/GuiClient/Program.cs new file mode 100644 index 00000000..bd69775b --- /dev/null +++ b/GuiClient/Program.cs @@ -0,0 +1,22 @@ +using Autofac; +using GuiClient.Forms; +using TagCloud; +using TagCloud.Client; + +namespace GuiClient; + +static class Program +{ + [STAThread] + static void Main() + { + var builder = new ContainerBuilder(); + builder.RegisterModule(new TagCloudModule()); + builder.RegisterType().As(); + builder.RegisterType().InstancePerDependency(); + var container = builder.Build(); + + var client = container.Resolve(); + client.Run(); + } +} \ No newline at end of file diff --git a/TagCloud/App.cs b/TagCloud/App.cs new file mode 100644 index 00000000..02ec0ec7 --- /dev/null +++ b/TagCloud/App.cs @@ -0,0 +1,38 @@ +using TagCloud.CloudPainter; +using TagCloud.Settings; +using TagCloud.TagPositioner; +using TagCloud.WordCounter; +using TagCloud.WordsProcessing; + +namespace TagCloud; + +internal class App( + IWordPreprocessor wordPreprocessor, + ITagCreator tagCreator, + ICloudPainter cloudPainter, + ITagPositioner tagPositioner, + IImageSettingsProvider imageSettingsProvider, + IAppSettingsProvider appSettingsProvider) +: IApp +{ + public void Run(AppSettings appSettings, ImageSettings imageSettings) + { + ValidateAppSettings(appSettings); + imageSettingsProvider.ImageSettings = imageSettings; + appSettingsProvider.AppSettings = appSettings; + + var words = wordPreprocessor.Process().ToArray(); + var tags = tagCreator.CreateTags(words); + tags = tagPositioner.Position(tags); + cloudPainter.Paint(tags.ToArray()); + } + + private void ValidateAppSettings(AppSettings appSettings) + { + if(string.IsNullOrEmpty(appSettings.SavePath)) + throw new ArgumentException("SavePath is required"); + + if(string.IsNullOrEmpty(appSettings.SourcePath)) + throw new ArgumentException("SourcePath is required"); + } +} \ No newline at end of file diff --git a/TagCloud/Client/IClient.cs b/TagCloud/Client/IClient.cs new file mode 100644 index 00000000..7976ff2d --- /dev/null +++ b/TagCloud/Client/IClient.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Client; + +public interface IClient +{ + void Run(); +} \ No newline at end of file diff --git a/TagCloud/CloudPainter/CloudPainter.cs b/TagCloud/CloudPainter/CloudPainter.cs new file mode 100644 index 00000000..27ef2245 --- /dev/null +++ b/TagCloud/CloudPainter/CloudPainter.cs @@ -0,0 +1,34 @@ +using System.Drawing; +using TagCloud.Settings; +using TagCloud.WordCounter; + +namespace TagCloud.CloudPainter; + +internal class CloudPainter(IImageSettingsProvider imageSettingsProvider, + IAppSettingsProvider appSettingsProvider, + IImageSaver imageSaver) + : ICloudPainter +{ + public void Paint(Tag[] tags) + { + using var bitmap = new Bitmap(imageSettingsProvider.ImageSettings.Width, + imageSettingsProvider.ImageSettings.Height); + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(Color.FromName(imageSettingsProvider.ImageSettings.BackgroundColor ?? "white")); + var fontFamily = new FontFamily(imageSettingsProvider.ImageSettings.FontFamily ?? "arial"); + + var random = new Random(); + foreach (var tag in tags) + { + var font = new Font(fontFamily, tag.Weight); + var color = Color.FromArgb(random.Next(100, 256), random.Next(100, 256), random.Next(100, 256)); + using var brush = new SolidBrush(color); + graphics.DrawString(tag.Word, font, brush, tag.Location); + } + + if(string.IsNullOrEmpty(appSettingsProvider.AppSettings.SavePath)) + throw new ArgumentException("Save path is required"); + + imageSaver.SaveImage(bitmap, appSettingsProvider.AppSettings.SavePath); + } +} \ No newline at end of file diff --git a/TagCloud/CloudPainter/ICloudPainter.cs b/TagCloud/CloudPainter/ICloudPainter.cs new file mode 100644 index 00000000..3fa9600c --- /dev/null +++ b/TagCloud/CloudPainter/ICloudPainter.cs @@ -0,0 +1,8 @@ +using TagCloud.WordCounter; + +namespace TagCloud.CloudPainter; + +internal interface ICloudPainter +{ + void Paint(Tag[] tags); +} \ No newline at end of file diff --git a/TagCloud/CloudPainter/IImageSaver.cs b/TagCloud/CloudPainter/IImageSaver.cs new file mode 100644 index 00000000..1dfc5c52 --- /dev/null +++ b/TagCloud/CloudPainter/IImageSaver.cs @@ -0,0 +1,8 @@ +using System.Drawing; + +namespace TagCloud.CloudPainter; + +internal interface IImageSaver +{ + void SaveImage(Bitmap image, string fullFileName); +} \ No newline at end of file diff --git a/TagCloud/CloudPainter/PngImageSaver.cs b/TagCloud/CloudPainter/PngImageSaver.cs new file mode 100644 index 00000000..1d54fbf1 --- /dev/null +++ b/TagCloud/CloudPainter/PngImageSaver.cs @@ -0,0 +1,13 @@ +using System.Drawing; +using System.Drawing.Imaging; + +namespace TagCloud.CloudPainter; + +internal class PngImageSaver : IImageSaver +{ + public void SaveImage(Bitmap image, string fullFileName) + { + image.Save(fullFileName, ImageFormat.Png); + image.Dispose(); + } +} \ No newline at end of file diff --git a/TagCloud/IApp.cs b/TagCloud/IApp.cs new file mode 100644 index 00000000..56abfc18 --- /dev/null +++ b/TagCloud/IApp.cs @@ -0,0 +1,8 @@ +using TagCloud.Settings; + +namespace TagCloud; + +public interface IApp +{ + void Run(AppSettings appSettings, ImageSettings imageSettings); +} \ No newline at end of file diff --git a/TagCloud/Settings/AppSettings.cs b/TagCloud/Settings/AppSettings.cs new file mode 100644 index 00000000..659ee904 --- /dev/null +++ b/TagCloud/Settings/AppSettings.cs @@ -0,0 +1,8 @@ +namespace TagCloud.Settings; + +public record AppSettings +{ + public string? SourcePath { get; init; } + public string? SavePath { get; init; } + public string? BoringWordsPath { get; init; } +} \ No newline at end of file diff --git a/TagCloud/Settings/AppSettingsProvider.cs b/TagCloud/Settings/AppSettingsProvider.cs new file mode 100644 index 00000000..dca77924 --- /dev/null +++ b/TagCloud/Settings/AppSettingsProvider.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Settings; + +internal class AppSettingsProvider : IAppSettingsProvider +{ + public AppSettings AppSettings { get; set; } = null!; +} \ No newline at end of file diff --git a/TagCloud/Settings/IAppSettingsProvider.cs b/TagCloud/Settings/IAppSettingsProvider.cs new file mode 100644 index 00000000..50a578a8 --- /dev/null +++ b/TagCloud/Settings/IAppSettingsProvider.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Settings; + +public interface IAppSettingsProvider +{ + AppSettings AppSettings { get; set; } +} \ No newline at end of file diff --git a/TagCloud/Settings/IImageSettingsProvider.cs b/TagCloud/Settings/IImageSettingsProvider.cs new file mode 100644 index 00000000..0795af4f --- /dev/null +++ b/TagCloud/Settings/IImageSettingsProvider.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Settings; + +public interface IImageSettingsProvider +{ + ImageSettings ImageSettings { get; set; } +} \ No newline at end of file diff --git a/TagCloud/Settings/ImageSettings.cs b/TagCloud/Settings/ImageSettings.cs new file mode 100644 index 00000000..7a3488f0 --- /dev/null +++ b/TagCloud/Settings/ImageSettings.cs @@ -0,0 +1,11 @@ +namespace TagCloud.Settings; + +public record ImageSettings +{ + public int Width { get; init; } + public int Height { get; init; } + public string? BackgroundColor { get; init; } + public string? FontFamily { get; init; } + public int FontSizeMax { get; init; } + public int FontSizeMin { get; init; } +} \ No newline at end of file diff --git a/TagCloud/Settings/ImageSettingsProvider.cs b/TagCloud/Settings/ImageSettingsProvider.cs new file mode 100644 index 00000000..e0b82f55 --- /dev/null +++ b/TagCloud/Settings/ImageSettingsProvider.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Settings; + +internal class ImageSettingsProvider : IImageSettingsProvider +{ + public ImageSettings ImageSettings { get; set; } = null!; +} \ No newline at end of file diff --git a/TagCloud/TagCloud.csproj b/TagCloud/TagCloud.csproj new file mode 100644 index 00000000..410f99a1 --- /dev/null +++ b/TagCloud/TagCloud.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + CA1416 + + + + + + + + + + + + + \ No newline at end of file diff --git a/TagCloud/TagCloudModule.cs b/TagCloud/TagCloudModule.cs new file mode 100644 index 00000000..eb4038e4 --- /dev/null +++ b/TagCloud/TagCloudModule.cs @@ -0,0 +1,28 @@ +using Autofac; +using TagCloud.CloudPainter; +using TagCloud.Settings; +using TagCloud.TagPositioner; +using TagCloud.TagPositioner.Circular; +using TagCloud.WordCounter; +using TagCloud.WordsProcessing; +using TagCloud.WordsReader; + +namespace TagCloud; + +public class TagCloudModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + } +} \ No newline at end of file diff --git a/TagCloud/TagPositioner/Circular/CircularCloudLayouter.cs b/TagCloud/TagPositioner/Circular/CircularCloudLayouter.cs new file mode 100644 index 00000000..1fd83e1a --- /dev/null +++ b/TagCloud/TagPositioner/Circular/CircularCloudLayouter.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using TagCloud.Settings; + +namespace TagCloud.TagPositioner.Circular; + +public class CircularCloudLayouter(IImageSettingsProvider imageSettingsProvider) : ICloudLayouter +{ + private Point _center; + private double _angle; + private const double SpiralStep = 0.2; + private const double AngleStep = 0.01; + + public Rectangle PutNextRectangle(Size rectangleSize, ICollection rectangles) + { + _center = new Point(imageSettingsProvider.ImageSettings.Width / 2, + imageSettingsProvider.ImageSettings.Height / 2); + Rectangle newRectangle; + if (!rectangles.Any()) + { + var rectangle = new Rectangle(_center, rectangleSize); + rectangles.Add(rectangle); + return rectangle; + } + + do + { + var location = GetLocation(rectangleSize); + newRectangle = new Rectangle(location, rectangleSize); + } while (rectangles.IsIntersecting(newRectangle)); + + rectangles.Add(newRectangle); + + return newRectangle; + } + + [SuppressMessage("ReSharper", "PossibleLossOfFraction")] + private Point GetLocation(Size rectangleSize) + { + var radius = SpiralStep * _angle; + var x = (int)(_center.X + radius * Math.Cos(_angle) - rectangleSize.Width / 2); + var y = (int)(_center.Y + radius * Math.Sin(_angle) - rectangleSize.Height / 2); + _angle += AngleStep; + + return new Point(x, y); + } +} \ No newline at end of file diff --git a/TagCloud/TagPositioner/Circular/CircularCloudLayouterExtensions.cs b/TagCloud/TagPositioner/Circular/CircularCloudLayouterExtensions.cs new file mode 100644 index 00000000..3d38e2ba --- /dev/null +++ b/TagCloud/TagPositioner/Circular/CircularCloudLayouterExtensions.cs @@ -0,0 +1,9 @@ +using System.Drawing; + +namespace TagCloud.TagPositioner.Circular; + +public static class CircularCloudLayouterExtensions +{ + public static bool IsIntersecting(this IEnumerable rectangles, Rectangle rectangle) + => rectangles.Any(existingRectangle => existingRectangle.IntersectsWith(rectangle)); +} \ No newline at end of file diff --git a/TagCloud/TagPositioner/Circular/ICloudLayouter.cs b/TagCloud/TagPositioner/Circular/ICloudLayouter.cs new file mode 100644 index 00000000..9d27e674 --- /dev/null +++ b/TagCloud/TagPositioner/Circular/ICloudLayouter.cs @@ -0,0 +1,8 @@ +using System.Drawing; + +namespace TagCloud.TagPositioner.Circular; + +public interface ICloudLayouter +{ + Rectangle PutNextRectangle(Size rectangleSize, ICollection rectangles); +} \ No newline at end of file diff --git a/TagCloud/TagPositioner/ITagPositioner.cs b/TagCloud/TagPositioner/ITagPositioner.cs new file mode 100644 index 00000000..d859ff00 --- /dev/null +++ b/TagCloud/TagPositioner/ITagPositioner.cs @@ -0,0 +1,8 @@ +using TagCloud.WordCounter; + +namespace TagCloud.TagPositioner; + +public interface ITagPositioner +{ + List Position(List tags); +} \ No newline at end of file diff --git a/TagCloud/TagPositioner/TagPositioner.cs b/TagCloud/TagPositioner/TagPositioner.cs new file mode 100644 index 00000000..66a4a421 --- /dev/null +++ b/TagCloud/TagPositioner/TagPositioner.cs @@ -0,0 +1,31 @@ +using System.Drawing; +using TagCloud.Settings; +using TagCloud.TagPositioner.Circular; +using TagCloud.WordCounter; + +namespace TagCloud.TagPositioner; + +public class TagPositioner(ICloudLayouter cloudLayouter, IImageSettingsProvider imageSettingsProvider) + : ITagPositioner +{ + public List Position(List tags) + { + var size = new Size(imageSettingsProvider.ImageSettings.Width, imageSettingsProvider.ImageSettings.Height); + var bitmap = new Bitmap(size.Width, size.Height); + using var graphics = Graphics.FromImage(bitmap); + + var fontFamily = new FontFamily(imageSettingsProvider.ImageSettings.FontFamily ?? "Arial"); + var rectangles = new List(); + + foreach (var tag in tags) + { + var font = new Font(fontFamily, tag.Weight); + + var textSize = graphics.MeasureString(tag.Word, font); + var res = cloudLayouter.PutNextRectangle(new Size((int)textSize.Width, (int)textSize.Height), rectangles); + tag.Location = new Point(res.X, res.Y); + } + + return tags; + } +} \ No newline at end of file diff --git a/TagCloud/WordCounter/ITagCreator.cs b/TagCloud/WordCounter/ITagCreator.cs new file mode 100644 index 00000000..4e20d916 --- /dev/null +++ b/TagCloud/WordCounter/ITagCreator.cs @@ -0,0 +1,6 @@ +namespace TagCloud.WordCounter; + +public interface ITagCreator +{ + List CreateTags(IEnumerable words); +} \ No newline at end of file diff --git a/TagCloud/WordCounter/Tag.cs b/TagCloud/WordCounter/Tag.cs new file mode 100644 index 00000000..3992b752 --- /dev/null +++ b/TagCloud/WordCounter/Tag.cs @@ -0,0 +1,11 @@ +using System.Drawing; + +namespace TagCloud.WordCounter; + +public class Tag +{ + public string Word { get; set; } = string.Empty; + public int Count { get; set; } + public int Weight { get; set; } + public Point Location { get; set; } +} \ No newline at end of file diff --git a/TagCloud/WordCounter/TagCreator.cs b/TagCloud/WordCounter/TagCreator.cs new file mode 100644 index 00000000..344565fc --- /dev/null +++ b/TagCloud/WordCounter/TagCreator.cs @@ -0,0 +1,33 @@ +using TagCloud.Settings; + +namespace TagCloud.WordCounter; + +public class TagCreator(IImageSettingsProvider imageSettingsProvider) : ITagCreator +{ + public List CreateTags(IEnumerable words) + { + if (!words.Any()) + return [..Array.Empty()]; + + var tags = words.GetCountInGroups() + .Select(x => + new Tag + { + Count = x.Count, + Word = x.Item + }) + .ToList(); + + var minCount = tags.Min(x => x.Count); + var maxCount = tags.Max(x => x.Count); + var minFontSize = imageSettingsProvider.ImageSettings.FontSizeMin; + var maxFontSize = imageSettingsProvider.ImageSettings.FontSizeMax; + + foreach (var tag in tags) + { + tag.Weight = (int)(minFontSize + (double)(tag.Count - minCount) / (maxCount - minCount) * (maxFontSize - minFontSize)); + } + + return tags; + } +} \ No newline at end of file diff --git a/TagCloud/WordCounter/WordCounterExtensions.cs b/TagCloud/WordCounter/WordCounterExtensions.cs new file mode 100644 index 00000000..69d47432 --- /dev/null +++ b/TagCloud/WordCounter/WordCounterExtensions.cs @@ -0,0 +1,9 @@ +namespace TagCloud.WordCounter; + +public static class WordCounterExtensions +{ + public static IEnumerable<(T Item, int Count)> GetCountInGroups(this IEnumerable source) => + source + .GroupBy(x => x) + .Select(x => (x.Key, x.Count())); +} \ No newline at end of file diff --git a/TagCloud/WordsProcessing/FIleBoringWordsProvider.cs b/TagCloud/WordsProcessing/FIleBoringWordsProvider.cs new file mode 100644 index 00000000..13f0611c --- /dev/null +++ b/TagCloud/WordsProcessing/FIleBoringWordsProvider.cs @@ -0,0 +1,13 @@ +using TagCloud.Settings; +using TagCloud.WordsReader; + +namespace TagCloud.WordsProcessing; + +public class FIleBoringWordsProvider(IAppSettingsProvider appSettingsProvider, IWordsReader wordsReader) + : IBoringWordsProvider +{ + public string[] GetWords() => + string.IsNullOrEmpty(appSettingsProvider.AppSettings.BoringWordsPath) + ? [] + : wordsReader.Read(appSettingsProvider.AppSettings.BoringWordsPath); +} \ No newline at end of file diff --git a/TagCloud/WordsProcessing/IBoringWordsProvider.cs b/TagCloud/WordsProcessing/IBoringWordsProvider.cs new file mode 100644 index 00000000..83fe6e4f --- /dev/null +++ b/TagCloud/WordsProcessing/IBoringWordsProvider.cs @@ -0,0 +1,6 @@ +namespace TagCloud.WordsProcessing; + +public interface IBoringWordsProvider +{ + string[] GetWords(); +} \ No newline at end of file diff --git a/TagCloud/WordsProcessing/IWordPreprocessor.cs b/TagCloud/WordsProcessing/IWordPreprocessor.cs new file mode 100644 index 00000000..825a0080 --- /dev/null +++ b/TagCloud/WordsProcessing/IWordPreprocessor.cs @@ -0,0 +1,6 @@ +namespace TagCloud.WordsProcessing; + +internal interface IWordPreprocessor +{ + string[] Process(); +} \ No newline at end of file diff --git a/TagCloud/WordsProcessing/WordPreprocessor.cs b/TagCloud/WordsProcessing/WordPreprocessor.cs new file mode 100644 index 00000000..295e6f81 --- /dev/null +++ b/TagCloud/WordsProcessing/WordPreprocessor.cs @@ -0,0 +1,23 @@ +using TagCloud.Settings; +using TagCloud.WordsReader; + +namespace TagCloud.WordsProcessing; + +internal class WordPreprocessor( + IBoringWordsProvider boringWordsProvider, + IWordsReader wordsReader, + IAppSettingsProvider appSettingsProvider) + : IWordPreprocessor +{ + public string[] Process() + { + if (appSettingsProvider.AppSettings.SourcePath == null) + throw new ArgumentException("Source path is required"); + + var boringWords = boringWordsProvider.GetWords(); + return wordsReader.Read(appSettingsProvider.AppSettings.SourcePath) + .Select(x => x.ToLower()) + .Where(x => !boringWords.Contains(x)) + .ToArray(); + } +} \ No newline at end of file diff --git a/TagCloud/WordsReader/IWordsReader.cs b/TagCloud/WordsReader/IWordsReader.cs new file mode 100644 index 00000000..605b0e79 --- /dev/null +++ b/TagCloud/WordsReader/IWordsReader.cs @@ -0,0 +1,6 @@ +namespace TagCloud.WordsReader; + +public interface IWordsReader +{ + string[] Read(string path); +} \ No newline at end of file diff --git a/TagCloud/WordsReader/TxtWordsReader.cs b/TagCloud/WordsReader/TxtWordsReader.cs new file mode 100644 index 00000000..55cd36f3 --- /dev/null +++ b/TagCloud/WordsReader/TxtWordsReader.cs @@ -0,0 +1,16 @@ +namespace TagCloud.WordsReader; + +public class TxtWordsReader : IWordsReader +{ + public string[] Read(string path) + { + try + { + return File.ReadLines(path).ToArray(); + } + catch (Exception e) + { + throw new Exception($"Во время чтения файла {path} произошла ошибка.", e); + } + } +} \ No newline at end of file diff --git a/TagCloudTests/BaseTest.cs b/TagCloudTests/BaseTest.cs new file mode 100644 index 00000000..db6de910 --- /dev/null +++ b/TagCloudTests/BaseTest.cs @@ -0,0 +1,32 @@ +using NSubstitute; + +namespace TagCloudTests; + +[TestFixture] +public class BaseTest where T : class +{ + protected T Sut { get; set; } + private object[] _constructorParameters; + + [SetUp] + public virtual void SetUp() + { + _constructorParameters = typeof(T) + .GetConstructors() + .First() + .GetParameters() + .Select(method => Substitute.For([method.ParameterType], null)) + .ToArray(); + Sut = Substitute.ForPartsOf(_constructorParameters); + } + + protected TInjectedService Mock() + { + var mock = _constructorParameters.FirstOrDefault(x => x is TInjectedService); + + if (mock == null) + throw new Exception($"Класс {nameof(TInjectedService)} не используется в классе {nameof(T)}"); + + return (TInjectedService)mock; + } +} \ No newline at end of file diff --git a/TagCloudTests/FileBoringWordsProviderTests.cs b/TagCloudTests/FileBoringWordsProviderTests.cs new file mode 100644 index 00000000..bc8cea8a --- /dev/null +++ b/TagCloudTests/FileBoringWordsProviderTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using NSubstitute; +using TagCloud.Settings; +using TagCloud.WordsProcessing; +using TagCloud.WordsReader; + +namespace TagCloudTests; + +public class FileBoringWordsProviderTests : BaseTest +{ + [Test] + public void GetWords_ShouldBeNotNullOrEmpty_WhenPathIsNotEmpty() + { + Mock() + .AppSettings + .Returns(new AppSettings {BoringWordsPath = "SomePath"}); + Mock() + .Read(Arg.Any()) + .Returns(["some", "word"]); + + var strings = Sut.GetWords(); + + strings.Should().NotBeNullOrEmpty(); + } + + [Test] + public void GetWords_ShouldBeEmpty_WhenPathIsNull() + { + Mock() + .AppSettings + .Returns(new AppSettings()); + + var strings = Sut.GetWords(); + + strings.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/TagCloudTests/Samples/BoringWords.txt b/TagCloudTests/Samples/BoringWords.txt new file mode 100644 index 00000000..8740704a --- /dev/null +++ b/TagCloudTests/Samples/BoringWords.txt @@ -0,0 +1,82 @@ +и +в +на +с +по +из +к +у +за +о +от +до +для +при +об +вокруг +над +под +между +без +вне +через +сквозь +как +что +где +когда +зачем +почему +кто +тот +это +этот +такой +весь +мой +твой +его +её +наш +ваш +их +себя +сам +что-то +кое-что +ничто +никто +нигде +никогда +всегда +тогда +сюда +туда +оттуда +здесь +там +всюду +везде +зачем-то +потому +почему-то +ну +ли +же +уж +ведь +либо +или +тоже +также +даже +лишь +едва +потом +затем +какой-то +такой-то +этакой +тот-то +этот-то +то есть \ No newline at end of file diff --git a/TagCloudTests/Samples/SampleWords.txt b/TagCloudTests/Samples/SampleWords.txt new file mode 100644 index 00000000..288b1eb9 --- /dev/null +++ b/TagCloudTests/Samples/SampleWords.txt @@ -0,0 +1,11 @@ +слово +в +на +пугало +мыш +в +слово +слово +пугало +рука +нога \ No newline at end of file diff --git a/TagCloudTests/TagCloudTest.cs b/TagCloudTests/TagCloudTest.cs new file mode 100644 index 00000000..61a19141 --- /dev/null +++ b/TagCloudTests/TagCloudTest.cs @@ -0,0 +1,46 @@ +using Autofac; +using TagCloud; +using TagCloud.Settings; + +namespace TagCloudTests; + +[TestFixture] +public class TagCloudTest +{ + private IContainer _container; + + [SetUp] + public void SetUp() + { + var builder = new ContainerBuilder(); + builder.RegisterModule(new TagCloudModule()); + _container = builder.Build(); + } + + [TearDown] + public void TearDown() => + _container.Dispose(); + + [Test] + public void Test() + { + var imageSettings = new ImageSettings + { + Width = 500, + Height = 500, + BackgroundColor = "red", + FontFamily = "Arial", + FontSizeMax = 40, + FontSizeMin = 10 + }; + var appSettings = new AppSettings + { + SourcePath = TestConstants.SamplesWordsTestFile, + SavePath = @"..\\..\..\test.png", + BoringWordsPath = TestConstants.BoringWordsTestFile + }; + + var app = _container.Resolve(); + app.Run(appSettings, imageSettings); + } +} \ No newline at end of file diff --git a/TagCloudTests/TagCloudTests.csproj b/TagCloudTests/TagCloudTests.csproj new file mode 100644 index 00000000..cb4cc093 --- /dev/null +++ b/TagCloudTests/TagCloudTests.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + \ No newline at end of file diff --git a/TagCloudTests/TagCreatorTests.cs b/TagCloudTests/TagCreatorTests.cs new file mode 100644 index 00000000..eaa94158 --- /dev/null +++ b/TagCloudTests/TagCreatorTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using NSubstitute; +using TagCloud.Settings; +using TagCloud.WordCounter; + +namespace TagCloudTests; + +[TestFixture] +public class TagCreatorTests : BaseTest +{ + public override void SetUp() + { + base.SetUp(); + Mock() + .ImageSettings + .Returns(new ImageSettings { FontSizeMax = 40, FontSizeMin = 10 }); + } + + [Test] + public void CreateTags_ShouldReturnEmptyList_WhenNoWordsProvided() + { + var words = Enumerable.Empty(); + var tags = Sut.CreateTags(words); + tags.Should().BeNullOrEmpty(); + } + + [Test] + public void CreateTags_ShouldCalculateTagWeightsCorrectly() + { + var words = new[] + { "apple", "banana", "apple", "cherry", "banana", "banana" }; // apple: 2, banana: 3, cherry: 1 + var tags = Sut.CreateTags(words); + + tags.Count.Should().Be(3); + var appleTag = tags.Single(t => t.Word == "apple"); + var bananaTag = tags.Single(t => t.Word == "banana"); + var cherryTag = tags.Single(t => t.Word == "cherry"); + + appleTag.Count.Should().Be(2); + bananaTag.Count.Should().Be(3); + cherryTag.Count.Should().Be(1); + + cherryTag.Weight.Should().Be(10); + bananaTag.Weight.Should().Be(40); + appleTag.Weight.Should().BeGreaterThan(10).And.BeLessThan(40); + } +} \ No newline at end of file diff --git a/TagCloudTests/TestConstants.cs b/TagCloudTests/TestConstants.cs new file mode 100644 index 00000000..81fc0708 --- /dev/null +++ b/TagCloudTests/TestConstants.cs @@ -0,0 +1,7 @@ +namespace TagCloudTests; + +public static class TestConstants +{ + public const string SamplesWordsTestFile = "Samples\\SampleWords.txt"; + public const string BoringWordsTestFile = "Samples\\BoringWords.txt"; +} \ No newline at end of file diff --git a/TagCloudTests/TxtWordsReaderTests.cs b/TagCloudTests/TxtWordsReaderTests.cs new file mode 100644 index 00000000..e05ffa88 --- /dev/null +++ b/TagCloudTests/TxtWordsReaderTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using TagCloud.WordsReader; + +namespace TagCloudTests; + +[TestFixture] +public class TxtWordsReaderTests +{ + private TxtWordsReader _txtWordsReader; + + [SetUp] + public void SetUp() => + _txtWordsReader = new TxtWordsReader(); + + [Test] + public void Read_ShouldNotBeNullOrEmpty() + { + var words = _txtWordsReader.Read(TestConstants.SamplesWordsTestFile); + words.Should().NotBeNullOrEmpty(); + } + + [Test] + public void Read_ShouldThrowExceptionIfFileDoesNotExist() + { + var act = () => _txtWordsReader.Read(""); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/TagCloudTests/WordPreprocessorTests.cs b/TagCloudTests/WordPreprocessorTests.cs new file mode 100644 index 00000000..12b0edcc --- /dev/null +++ b/TagCloudTests/WordPreprocessorTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using NSubstitute; +using TagCloud.Settings; +using TagCloud.WordsProcessing; +using TagCloud.WordsReader; + +namespace TagCloudTests; + +internal class WordPreprocessorTests : BaseTest +{ + [SetUp] + public override void SetUp() + { + base.SetUp(); + + Mock() + .AppSettings + .Returns(new AppSettings { SourcePath = "Path" }); + } + + [Test] + public void Process_WordsShouldFilter() + { + Mock() + .GetWords() + .Returns(["в", "на"]); + Mock() + .Read(Arg.Any()) + .Returns(["в", "на", "привет"]); + + var processedWords = Sut.Process(); + + processedWords.Should().NotBeNullOrEmpty(); + processedWords.Length.Should().Be(1); + processedWords.First().Should().Be("привет"); + } + + [Test] + public void Process_WordsShouldBeLowercase() + { + Mock() + .Read(Arg.Any()) + .Returns(["ПРИВЕТ"]); + + var processedWords = Sut.Process(); + + processedWords.First().Should().Be("привет"); + } +} \ No newline at end of file diff --git a/TagCloudTests/test.png b/TagCloudTests/test.png new file mode 100644 index 00000000..f59be397 Binary files /dev/null and b/TagCloudTests/test.png differ diff --git a/di.sln b/di.sln index a50991da..e6deb0c4 100644 --- a/di.sln +++ b/di.sln @@ -2,6 +2,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "di", "FractalPainter/di.csproj", "{4B70F6B3-5C20-40D2-BFC9-D95C18D65DD6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagCloud", "TagCloud\TagCloud.csproj", "{873CB7DF-3A3A-4E2C-8BC4-B44C1E95F1F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleClient", "ConsoleClient\ConsoleClient.csproj", "{DA145115-D7D1-45D2-A84B-836D31A0CD46}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuiClient", "GuiClient\GuiClient.csproj", "{F803F3E8-4812-4E04-AE32-A6A912CB5576}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagCloudTests", "TagCloudTests\TagCloudTests.csproj", "{914795A4-014C-4AB2-967E-49B399EF0820}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +20,21 @@ Global {4B70F6B3-5C20-40D2-BFC9-D95C18D65DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU {4B70F6B3-5C20-40D2-BFC9-D95C18D65DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU {4B70F6B3-5C20-40D2-BFC9-D95C18D65DD6}.Release|Any CPU.Build.0 = Release|Any CPU + {873CB7DF-3A3A-4E2C-8BC4-B44C1E95F1F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {873CB7DF-3A3A-4E2C-8BC4-B44C1E95F1F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {873CB7DF-3A3A-4E2C-8BC4-B44C1E95F1F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {873CB7DF-3A3A-4E2C-8BC4-B44C1E95F1F2}.Release|Any CPU.Build.0 = Release|Any CPU + {DA145115-D7D1-45D2-A84B-836D31A0CD46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA145115-D7D1-45D2-A84B-836D31A0CD46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA145115-D7D1-45D2-A84B-836D31A0CD46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA145115-D7D1-45D2-A84B-836D31A0CD46}.Release|Any CPU.Build.0 = Release|Any CPU + {F803F3E8-4812-4E04-AE32-A6A912CB5576}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F803F3E8-4812-4E04-AE32-A6A912CB5576}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F803F3E8-4812-4E04-AE32-A6A912CB5576}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F803F3E8-4812-4E04-AE32-A6A912CB5576}.Release|Any CPU.Build.0 = Release|Any CPU + {914795A4-014C-4AB2-967E-49B399EF0820}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {914795A4-014C-4AB2-967E-49B399EF0820}.Debug|Any CPU.Build.0 = Debug|Any CPU + {914795A4-014C-4AB2-967E-49B399EF0820}.Release|Any CPU.ActiveCfg = Release|Any CPU + {914795A4-014C-4AB2-967E-49B399EF0820}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal