diff --git a/TagsCloudContainer.Console/CommandLineOptions.cs b/TagsCloudContainer.Console/CommandLineOptions.cs new file mode 100644 index 000000000..64889d37e --- /dev/null +++ b/TagsCloudContainer.Console/CommandLineOptions.cs @@ -0,0 +1,41 @@ +using CommandLine; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Infrastructure.Layout; + +namespace TagsCloudContainer.Console; +public class CommandLineOptions +{ + [Option('i', "input", Required = true, HelpText = "Input file path")] + public string InputFile { get; init; } = string.Empty; + + [Option('o', "output", Required = true, HelpText = "Output file path")] + public string OutputFile { get; init; } = string.Empty; + + [Option('w', "width", Default = 800, HelpText = "Image width")] + public int Width { get; init; } + + [Option('h', "height", Default = 600, HelpText = "Image height")] + public int Height { get; init; } + + [Option('f', "font", Default = "Arial", HelpText = "Font family")] + public string FontFamily { get; init; } = string.Empty; + + [Option('c', "colors", HelpText = "Color scheme (Random/Gradient)")] + public ColorSchemeType ColorScheme { get; init; } = ColorSchemeType.Random; + + [Option("boring-words", HelpText = "Path to file with boring words")] + public string? BoringWordsFile { get; init; } + + [Option("min-font", Default = 10, HelpText = "Minimum font size")] + public int MinFontSize { get; init; } + + [Option("max-font", Default = 50, HelpText = "Maximum font size")] + public int MaxFontSize { get; init; } + + [Option("algorithm", Default = TagCloudAlgorithmType.Spiral, HelpText = + "Algorithm: Spiral | Tight")] + public TagCloudAlgorithmType Algorithm { get; init; } = TagCloudAlgorithmType.Spiral; + + [Option("settings", HelpText = "Path to JSON settings file")] + public string? SettingsFile { get; init; } +} diff --git a/TagsCloudContainer.Console/ConsoleClient.cs b/TagsCloudContainer.Console/ConsoleClient.cs new file mode 100644 index 000000000..4a99998aa --- /dev/null +++ b/TagsCloudContainer.Console/ConsoleClient.cs @@ -0,0 +1,44 @@ +using ResultOf; +using TagsCloudContainer.Core.Infrastructure.Analysis; +using TagsCloudContainer.Core.Infrastructure.Layout; +using TagsCloudContainer.Core.Infrastructure.Preprocessing; +using TagsCloudContainer.Core.Infrastructure.Reading; +using TagsCloudContainer.Core.Infrastructure.Rendering; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Console; +public class ConsoleClient( + ITextReader textReader, + ITextPreprocessor preprocessor, + IWordFrequencyAnalyzer analyzer, + ITagCloudAlgorithm algorithm, + ITagCloudRenderer renderer) +{ + public Result GenerateTagCloud( + string inputFile, + string outputFile, + LayoutOptions options) + { + System.Console.WriteLine($"Reading words from {inputFile}..."); + + return textReader.ReadWords(inputFile) + .Then(preprocessor.Process) + .Then(EnsureNotEmpty) + .Then(analyzer.Analyze) + .Then(freqs => + algorithm.Arrange(freqs, options)) + .Then(layout => + { + System.Console.WriteLine($"Rendering image ({options.Width} x{options.Height})..."); + return renderer.Render(layout, options); + }) + .Then(image => renderer.SaveToFile(image, outputFile)) + .RefineError("Tag cloud generation failed"); + } + private static Result> EnsureNotEmpty(IReadOnlyList words) + { + return words.Count == 0 + ? Result.Fail>("No words to build a tag cloud") + : Result.Ok(words); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Console/Program.cs b/TagsCloudContainer.Console/Program.cs new file mode 100644 index 000000000..3cc63be6f --- /dev/null +++ b/TagsCloudContainer.Console/Program.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using Autofac; +using CommandLine; +using TagsCloudContainer.Console; +using TagsCloudContainer.Core.DI; +using TagsCloudContainer.Core.Models; +using ResultOf; + +var parser = new Parser(with => with.HelpWriter = Console.Out); + +return parser.ParseArguments(args) + .MapResult( + options => + { + return ValidateCommandLineOptions(options) + .Then(LoadSettings) + .Then(settings => + { + var layoutOptions = new LayoutOptions + { + Width = settings.Width, + Height = settings.Height, + FontFamily = settings.FontFamily + }; + + return Result.Of(() => + { + var builder = new ContainerBuilder(); + builder.RegisterModule(new AutofacModule(settings)); + builder.RegisterInstance(options).As(); + builder.RegisterType().SingleInstance(); + var container = builder.Build(); + return container.Resolve(); + }, "Failed to initialize DI container") + .Then(client => + client.GenerateTagCloud(options.InputFile, options.OutputFile, layoutOptions)); + }) + .OnFail(err => Console.Error.WriteLine("Error: " + err)) + .IsSuccess ? 0 : 1; + }, + errors => + { + foreach (var e in errors) Console.Error.WriteLine(e.ToString()); + return 1; + }); + +static Result ValidateCommandLineOptions(CommandLineOptions o) +{ + if (string.IsNullOrWhiteSpace(o.InputFile)) + return Result.Fail("Input file is not specified"); + + return string.IsNullOrWhiteSpace(o.OutputFile) + ? Result.Fail("Output file is not specified") + : Result.Ok(o); +} + +static TagCloudSettingsDto CreateSettingsFromOptions(CommandLineOptions o) +{ + return new TagCloudSettingsDto + { + Width = o.Width, + Height = o.Height, + FontFamily = o.FontFamily, + ColorScheme = o.ColorScheme, + MinFontSize = o.MinFontSize, + MaxFontSize = o.MaxFontSize, + Algorithm = o.Algorithm, + BoringWordsPath = o.BoringWordsFile + }; +} + +static Result LoadSettings(CommandLineOptions o) +{ + if (string.IsNullOrWhiteSpace(o.SettingsFile)) + return TagCloudSettings.Create(CreateSettingsFromOptions(o)); + + if (!File.Exists(o.SettingsFile)) + return Result.Fail($"Settings file not found: {o.SettingsFile}"); + + return Result.Of(() => File.ReadAllText(o.SettingsFile), + $"Failed to read settings file: {o.SettingsFile}") + .Then(DeserializeSettings) + .Then(settings => TagCloudSettings.Create(OverrideBoringWords(settings, o.BoringWordsFile))); + + static Result DeserializeSettings(string json) + { + try + { + var settings = JsonSerializer.Deserialize( + json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return settings == null + ? Result.Fail("Settings file is empty or invalid.") + : Result.Ok(settings); + } + catch (Exception e) + { + return Result.Fail($"Failed to parse settings file: {e.Message}"); + } + } +} + +static TagCloudSettingsDto OverrideBoringWords(TagCloudSettingsDto s, string? path) +{ + if (string.IsNullOrWhiteSpace(path)) + return s; + + return new TagCloudSettingsDto + { + Width = s.Width, + Height = s.Height, + FontFamily = s.FontFamily, + ColorScheme = s.ColorScheme, + MinFontSize = s.MinFontSize, + MaxFontSize = s.MaxFontSize, + Algorithm = s.Algorithm, + BoringWordsPath = path + }; +} diff --git a/TagsCloudContainer.Console/TagsCloudContainer.Console.csproj b/TagsCloudContainer.Console/TagsCloudContainer.Console.csproj new file mode 100644 index 000000000..a3547b7fd --- /dev/null +++ b/TagsCloudContainer.Console/TagsCloudContainer.Console.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/TagsCloudContainer.Core/.DS_Store b/TagsCloudContainer.Core/.DS_Store new file mode 100644 index 000000000..6948305f8 Binary files /dev/null and b/TagsCloudContainer.Core/.DS_Store differ diff --git a/TagsCloudContainer.Core/DI/AutofacModule.cs b/TagsCloudContainer.Core/DI/AutofacModule.cs new file mode 100644 index 000000000..a364079ad --- /dev/null +++ b/TagsCloudContainer.Core/DI/AutofacModule.cs @@ -0,0 +1,56 @@ +using Autofac; +using SkiaSharp; +using TagsCloudContainer.Core.Infrastructure.Analysis; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Infrastructure.Layout; +using TagsCloudContainer.Core.Infrastructure.Preprocessing; +using TagsCloudContainer.Core.Infrastructure.Reading; +using TagsCloudContainer.Core.Infrastructure.Rendering; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.DI; +public class AutofacModule(TagCloudSettings settings) : Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterInstance(settings).As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType() .As().SingleInstance(); + builder.Register(_ => + string.IsNullOrWhiteSpace(settings.BoringWordsPath) + ? new DefaultBoringWordsProvider() + : new FileBoringWordsProvider(settings.BoringWordsPath)) + .SingleInstance(); + builder.Register(c => + { + var boringWords = c.Resolve(); + return new CompositePreprocessor([ + new LowerCaseNormalizer(), + new BoringWordsFilter(boringWords) + ]); + }).SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.Register(_ + => new LinearFontSizeCalculator(settings.MinFontSize, settings.MaxFontSize)).SingleInstance(); + builder.RegisterType() + .Keyed(ColorSchemeType.Random) + .SingleInstance(); + builder.Register(_ => new GradientColorScheme(SKColors.Blue, SKColors.LightBlue)) + .Keyed(ColorSchemeType.Gradient) + .SingleInstance(); + builder.Register(ctx => + ctx.ResolveKeyed(settings.ColorScheme)) + .SingleInstance(); + builder.RegisterType() + .Keyed(TagCloudAlgorithmType.Spiral) + .SingleInstance(); + builder.RegisterType() + .Keyed(TagCloudAlgorithmType.Tight) + .SingleInstance(); + builder.Register(ctx => + ctx.ResolveKeyed(settings.Algorithm)) + .SingleInstance(); + builder.RegisterType().As().SingleInstance(); + } +} diff --git a/TagsCloudContainer.Core/DI/CompositePreprocessor.cs b/TagsCloudContainer.Core/DI/CompositePreprocessor.cs new file mode 100644 index 000000000..50f3282aa --- /dev/null +++ b/TagsCloudContainer.Core/DI/CompositePreprocessor.cs @@ -0,0 +1,13 @@ +using ResultOf; +using TagsCloudContainer.Core.Infrastructure.Preprocessing; + +namespace TagsCloudContainer.Core.DI; +public class CompositePreprocessor(IEnumerable preprocessors) : ITextPreprocessor +{ + public Result> Process(IEnumerable words) + { + var current = Result.Ok>(words.ToArray()); + return preprocessors.Aggregate(current, (current1, preprocessor) + => current1.Then(preprocessor.Process)); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/.DS_Store b/TagsCloudContainer.Core/Infrastructure/.DS_Store new file mode 100644 index 000000000..168115619 Binary files /dev/null and b/TagsCloudContainer.Core/Infrastructure/.DS_Store differ diff --git a/TagsCloudContainer.Core/Infrastructure/Analysis/IWordFrequencyAnalyzer.cs b/TagsCloudContainer.Core/Infrastructure/Analysis/IWordFrequencyAnalyzer.cs new file mode 100644 index 000000000..4ccd08c42 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Analysis/IWordFrequencyAnalyzer.cs @@ -0,0 +1,9 @@ +using ResultOf; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Analysis; + +public interface IWordFrequencyAnalyzer +{ + Result> Analyze(IEnumerable words); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Analysis/WordFrequencyAnalyzer.cs b/TagsCloudContainer.Core/Infrastructure/Analysis/WordFrequencyAnalyzer.cs new file mode 100644 index 000000000..4538ad644 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Analysis/WordFrequencyAnalyzer.cs @@ -0,0 +1,16 @@ +using ResultOf; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Analysis; +public class WordFrequencyAnalyzer : IWordFrequencyAnalyzer +{ + public Result> Analyze(IEnumerable words) + { + var frequencies = words + .GroupBy(word => word) + .Select(group + => new WordFrequency(group.Key, group.Count())) + .OrderByDescending(wf => wf.Frequency).ToArray(); + return Result.Ok>(frequencies); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Coloring/ColorSchemeType.cs b/TagsCloudContainer.Core/Infrastructure/Coloring/ColorSchemeType.cs new file mode 100644 index 000000000..2864c6571 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Coloring/ColorSchemeType.cs @@ -0,0 +1,6 @@ +namespace TagsCloudContainer.Core.Infrastructure.Coloring; +public enum ColorSchemeType +{ + Random, + Gradient +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Coloring/GradientColorScheme.cs b/TagsCloudContainer.Core/Infrastructure/Coloring/GradientColorScheme.cs new file mode 100644 index 000000000..a7610398f --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Coloring/GradientColorScheme.cs @@ -0,0 +1,20 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Infrastructure.Coloring; +public class GradientColorScheme(SKColor startColor, SKColor endColor) : IColorScheme +{ + public SKColor GetColor(int seed) + { + var hash = Math.Abs(seed); + var ratio = (hash % 100) / 100.0f; + return InterpolateColor(startColor, endColor, ratio); + } + + private static SKColor InterpolateColor(SKColor start, SKColor end, float ratio) + { + var r = (byte)(start.Red + (end.Red - start.Red) * ratio); + var g = (byte)(start.Green + (end.Green - start.Green) * ratio); + var b = (byte)(start.Blue + (end.Blue - start.Blue) * ratio); + return new SKColor(r, g, b); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Coloring/IColorScheme.cs b/TagsCloudContainer.Core/Infrastructure/Coloring/IColorScheme.cs new file mode 100644 index 000000000..a777cd358 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Coloring/IColorScheme.cs @@ -0,0 +1,7 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Infrastructure.Coloring; +public interface IColorScheme +{ + SKColor GetColor(int seed); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Coloring/RandomColorScheme.cs b/TagsCloudContainer.Core/Infrastructure/Coloring/RandomColorScheme.cs new file mode 100644 index 000000000..e97b27da0 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Coloring/RandomColorScheme.cs @@ -0,0 +1,22 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Infrastructure.Coloring; +public class RandomColorScheme : IColorScheme +{ + private readonly SKColor[] _colors = + [ + SKColors.Red, SKColors.Blue, SKColors.Green, SKColors.Purple, + SKColors.Orange, new(139, 0, 0), + new(0, 0, 139), + new(0, 100, 0), + new(165, 42, 42), + new(0, 139, 139), + new(139, 0, 139) + ]; + + public SKColor GetColor(int seed) + { + var index = Math.Abs(seed) % _colors.Length; + return _colors[index]; + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/ArchimedeanSpiralPointGenerator.cs b/TagsCloudContainer.Core/Infrastructure/Layout/ArchimedeanSpiralPointGenerator.cs new file mode 100644 index 000000000..4d9809cee --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/ArchimedeanSpiralPointGenerator.cs @@ -0,0 +1,19 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Infrastructure.Layout; +internal class ArchimedeanSpiralPointGenerator( + SKPoint center, + double angleStep = 0.1, + double radiusStep = 0.5) +{ + private double _angle; + + public SKPoint GetNextPoint() + { + var radius = radiusStep * _angle; + var x = center.X + (float)Math.Round(radius * Math.Cos(_angle)); + var y = center.Y + (float)Math.Round(radius * Math.Sin(_angle)); + _angle += angleStep; + return new SKPoint(x, y); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/IFontSizeCalculator.cs b/TagsCloudContainer.Core/Infrastructure/Layout/IFontSizeCalculator.cs new file mode 100644 index 000000000..579226793 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/IFontSizeCalculator.cs @@ -0,0 +1,5 @@ +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public interface IFontSizeCalculator +{ + float Calculate(int frequency, int maxFrequency); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/ITagCloudAlgorithm.cs b/TagsCloudContainer.Core/Infrastructure/Layout/ITagCloudAlgorithm.cs new file mode 100644 index 000000000..9151f2573 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/ITagCloudAlgorithm.cs @@ -0,0 +1,10 @@ +using ResultOf; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public interface ITagCloudAlgorithm +{ + Result> Arrange( + IEnumerable wordFrequencies, + LayoutOptions options); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/LinearFontSizeCalculator.cs b/TagsCloudContainer.Core/Infrastructure/Layout/LinearFontSizeCalculator.cs new file mode 100644 index 000000000..e23f75eaf --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/LinearFontSizeCalculator.cs @@ -0,0 +1,13 @@ +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public class LinearFontSizeCalculator(float minFontSize = 10, float maxFontSize = 50) + : IFontSizeCalculator +{ + public float Calculate(int frequency, int maxFrequency) + { + if (maxFrequency <= 0) + return minFontSize; + var ratio = (float)frequency / maxFrequency; + var fontSize = minFontSize + (maxFontSize - minFontSize) * ratio; + return Math.Max(minFontSize, Math.Min(maxFontSize, fontSize)); + } +} diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/RectangleCloudLayouter.cs b/TagsCloudContainer.Core/Infrastructure/Layout/RectangleCloudLayouter.cs new file mode 100644 index 000000000..599e6e498 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/RectangleCloudLayouter.cs @@ -0,0 +1,84 @@ +using ResultOf; +using SkiaSharp; + +namespace TagsCloudContainer.Core.Infrastructure.Layout; +internal class RectangleCloudLayouter( + SKPoint center, + ArchimedeanSpiralPointGenerator generator) +{ + private readonly List rectangles = []; + public Result PutNextRectangle(SKSize size, SKRect bounds, int maxAttempts = 10000) + { + if (size.Width <= 0 || size.Height <= 0) + return Result.Fail("Rectangle size must be greater than zero"); + + if (rectangles.Count != 0) + return FindPlace(size, bounds, maxAttempts).Then(rect => + { + var shifted = ShiftToCenter(rect); + if (!IsInside(shifted, bounds)) + return Result.Fail("Tag cloud does not fit the image"); + rectangles.Add(shifted); + return Result.Ok(shifted); + }); + var first = CreateRect(center, size); + if (!IsInside(first, bounds)) + return Result.Fail("Tag cloud does not fit the image"); + rectangles.Add(first); + return Result.Ok(first); + } + + private Result FindPlace(SKSize size, SKRect bounds, int + maxAttempts) + { + for (var i = 0; i < maxAttempts; i++) + { + var point = generator.GetNextPoint(); + var candidate = CreateRect(point, size); + if (rectangles.All(r => !r.IntersectsWith(candidate)) && + IsInside(candidate, bounds)) + return Result.Ok(candidate); + } + return Result.Fail("Tag cloud does not fit the image"); + } + + private SKRect ShiftToCenter(SKRect rect) + { + while (true) + { + var direction = GetDirectionToCenter(rect); + if (direction == SKPoint.Empty) + return rect; + var shifted = OffsetClone(rect, direction.X, direction.Y); + if (rectangles.Any(r => r.IntersectsWith(shifted))) + return rect; + rect = shifted; + } + } + + private static bool IsInside(SKRect rect, SKRect bounds) + { + return rect.Left >= bounds.Left && + rect.Top >= bounds.Top && + rect.Right <= bounds.Right && + rect.Bottom <= bounds.Bottom; + } + + private SKPoint GetDirectionToCenter(SKRect rect) + { + var center1 = new SKPoint((rect.Left + rect.Right) / 2f, (rect.Top + rect.Bottom) / 2f); + var dx = center1.X > center.X ? -1 : center1.X < center.X ? 1 : 0; + var dy = center1.Y > center.Y ? -1 : center1.Y < center.Y ? 1 : 0; + return new SKPoint(dx, dy); + } + + private static SKRect CreateRect(SKPoint center, SKSize size) + { + var left = center.X - size.Width / 2; + var top = center.Y - size.Height / 2; + return new SKRect(left, top, left + size.Width, top + size.Height); + } + + private static SKRect OffsetClone(SKRect rect, float dx, float dy) => + new(rect.Left + dx, rect.Top + dy, rect.Right + dx, rect.Bottom + dy); +} diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/SpiralTagCloudAlgorithm.cs b/TagsCloudContainer.Core/Infrastructure/Layout/SpiralTagCloudAlgorithm.cs new file mode 100644 index 000000000..6778c0c2f --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/SpiralTagCloudAlgorithm.cs @@ -0,0 +1,91 @@ +using ResultOf; +using SkiaSharp; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public class SpiralTagCloudAlgorithm( + IFontSizeCalculator fontSizeCalculator, + IColorScheme colorScheme) + : ITagCloudAlgorithm +{ + public Result> Arrange( + IEnumerable wordFrequencies, + LayoutOptions options) + { + var frequencies = wordFrequencies.ToArray(); + if (frequencies.Length == 0) + return + Result.Ok>([]); + + var typeface = SKFontManager.Default.MatchFamily(options.FontFamily); + if (typeface == null) + return Result.Fail>($"Font'{options.FontFamily}' not found."); + + var maxFrequency = frequencies.Max(wf => wf.Frequency); + var placedRectangles = new List(); + var layoutWords = new List(); + var bounds = new SKRect(0, 0, options.Width, options.Height); + + foreach (var wordFrequency in frequencies) + { + var fontSize = fontSizeCalculator.Calculate( + wordFrequency.Frequency, maxFrequency); + var size = MeasureWordSize(wordFrequency.Word, typeface, fontSize); + var location = FindLocationForRectangle(size, options, bounds, placedRectangles); + if (location == SKPoint.Empty) + return Result.Fail>( + "Tag cloud doesn't fit the image"); + + var rectangle = new SKRect(location.X, location.Y, + location.X + size.Width, location.Y + size.Height); + placedRectangles.Add(rectangle); + var color = colorScheme.GetColor(wordFrequency.Word.GetHashCode()); + layoutWords.Add(new LayoutWord(wordFrequency, typeface, fontSize, color, rectangle)); + } + return Result.Ok>(layoutWords); + } + + private static SKSize MeasureWordSize( + string word, + SKTypeface typeface, + float fontSize) + { + using var paint = new SKPaint(); + paint.Typeface = typeface; + paint.TextSize = fontSize; + var bounds = new SKRect(); + paint.MeasureText(word, ref bounds); + return new SKSize(bounds.Width, bounds.Height); + } + + private static SKPoint FindLocationForRectangle( + SKSize size, + LayoutOptions options, + SKRect bounds, + List placedRectangles) + { + const double angleStep = 0.1; + const double radiusStep = 1; + double angle = 0; + double radius = 0; + for (var i = 0; i < 1000; i++) + { + var x = options.Center.X + (float)(radius * Math.Cos(angle)) - size.Width / 2; + var y = options.Center.Y + (float)(radius * Math.Sin(angle)) - size.Height / 2; + var candidate = new SKRect(x, y, x + size.Width, y + size.Height); + var fits = candidate.Left >= bounds.Left && + candidate.Top >= bounds.Top && + candidate.Right <= bounds.Right && + candidate.Bottom <= bounds.Bottom; + if (fits && !placedRectangles.Any(rect => + rect.IntersectsWith(candidate))) + return new SKPoint(x, y); + + angle += angleStep; + radius += radiusStep; + } + return SKPoint.Empty; + } +} + diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/TagCloudAlgorithmType.cs b/TagsCloudContainer.Core/Infrastructure/Layout/TagCloudAlgorithmType.cs new file mode 100644 index 000000000..e59ec94e6 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/TagCloudAlgorithmType.cs @@ -0,0 +1,6 @@ +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public enum TagCloudAlgorithmType +{ + Spiral, + Tight +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Layout/TightSpiralTagCloudAlgorithm.cs b/TagsCloudContainer.Core/Infrastructure/Layout/TightSpiralTagCloudAlgorithm.cs new file mode 100644 index 000000000..d7a5742d3 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Layout/TightSpiralTagCloudAlgorithm.cs @@ -0,0 +1,58 @@ +using ResultOf; +using SkiaSharp; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Layout; +public class TightSpiralTagCloudAlgorithm( + IFontSizeCalculator fontSizeCalculator, + IColorScheme colorScheme) + : ITagCloudAlgorithm +{ + public Result> Arrange(IEnumerable + wordFrequencies, LayoutOptions options) + { + var frequencies = wordFrequencies.ToArray(); + if (frequencies.Length == 0) + return Result.Ok>([]); + + var typeface = SKFontManager.Default.MatchFamily(options.FontFamily); + if (typeface == null) + return Result.Fail>($"Font'{options.FontFamily}' not found"); + + var bounds = new SKRect(0, 0, options.Width, options.Height); + var layouter = new RectangleCloudLayouter(options.Center, + new ArchimedeanSpiralPointGenerator(options.Center)); + + var maxFrequency = frequencies.Max(x => x.Frequency); + var layoutWords = new List(); + + foreach (var wordFrequency in frequencies) + { + var fontSize = fontSizeCalculator.Calculate(wordFrequency.Frequency, + maxFrequency); + var size = MeasureWordSize(wordFrequency.Word, typeface, fontSize); + var rectResult = layouter.PutNextRectangle(size, bounds); + if (rectResult is { IsSuccess: false, Error: not null }) + return Result.Fail>(rectResult.Error); + + var rect = rectResult.GetValueOrThrow(); + var color = colorScheme.GetColor(wordFrequency.Word.GetHashCode()); + layoutWords.Add(new LayoutWord(wordFrequency, typeface, fontSize, color, rect)); + } + return Result.Ok>(layoutWords); + } + + private static SKSize MeasureWordSize( + string word, + SKTypeface typeface, + float fontSize) + { + using var paint = new SKPaint(); + paint.Typeface = typeface; + paint.TextSize = fontSize; + var bounds = new SKRect(); + paint.MeasureText(word, ref bounds); + return new SKSize(bounds.Width, bounds.Height); + } +} diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/BoringWordsFilter.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/BoringWordsFilter.cs new file mode 100644 index 000000000..6b1e24563 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/BoringWordsFilter.cs @@ -0,0 +1,16 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public class BoringWordsFilter(IBoringWordsProvider provider) : ITextPreprocessor +{ + public Result> Process(IEnumerable words) + { + return provider.GetWords().Then(boringWords => + { + var set = new HashSet(boringWords, + StringComparer.OrdinalIgnoreCase); + var filtered = words.Where(word => !set.Contains(word)).ToArray(); + return Result.Ok>(filtered); + }); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/DefaultBoringWordsProvider.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/DefaultBoringWordsProvider.cs new file mode 100644 index 000000000..fab585046 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/DefaultBoringWordsProvider.cs @@ -0,0 +1,16 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public class DefaultBoringWordsProvider : IBoringWordsProvider +{ + public Result GetWords() => Result.Ok(DefaultBoringWords); + + private static readonly string[] DefaultBoringWords = + [ + "и", "в", "не", "на", "я", "он", "с", "что", "а", "по", + "это", "как", "но", "они", "к", "у", "же", "вы", "за", + "бы", "о", "из", "от", "то", "все", "его", "до", "для", + "мы", "при", "так", "та", "их", "чем", "или", "быть", + "вот", "от", "под", "ну", "ни", "да", "ли", "если", "еще" + ]; +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/EmptyBoringWordsProvider.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/EmptyBoringWordsProvider.cs new file mode 100644 index 000000000..757b0b324 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/EmptyBoringWordsProvider.cs @@ -0,0 +1,7 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public class EmptyBoringWordsProvider : IBoringWordsProvider +{ + public Result GetWords() => Result.Ok(Array.Empty()); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/FileBoringWordsProvider.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/FileBoringWordsProvider.cs new file mode 100644 index 000000000..77e9717a7 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/FileBoringWordsProvider.cs @@ -0,0 +1,15 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public class FileBoringWordsProvider(string path) : IBoringWordsProvider +{ + public Result GetWords() + { + if (string.IsNullOrWhiteSpace(path)) + return Result.Ok(Array.Empty()); + if (!File.Exists(path)) + return Result.Fail($"Boring words file not found {path}"); + return Result.Of(() => File.ReadAllLines(path), + $"Failed to read boring words file {path}"); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/IBoringWordsProvider.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/IBoringWordsProvider.cs new file mode 100644 index 000000000..cc84652c0 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/IBoringWordsProvider.cs @@ -0,0 +1,7 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public interface IBoringWordsProvider +{ + Result GetWords(); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/ITextPreprocessor.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/ITextPreprocessor.cs new file mode 100644 index 000000000..34b1a9b03 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/ITextPreprocessor.cs @@ -0,0 +1,7 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public interface ITextPreprocessor +{ + Result> Process(IEnumerable words); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Preprocessing/LowerCaseNormalizer.cs b/TagsCloudContainer.Core/Infrastructure/Preprocessing/LowerCaseNormalizer.cs new file mode 100644 index 000000000..d37d7b0c6 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Preprocessing/LowerCaseNormalizer.cs @@ -0,0 +1,9 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Preprocessing; +public class LowerCaseNormalizer : ITextPreprocessor +{ + public Result> Process(IEnumerable words) => + Result.Ok>(words.Select(w => + w.ToLowerInvariant()).ToArray()); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Reading/DocxTextReader.cs b/TagsCloudContainer.Core/Infrastructure/Reading/DocxTextReader.cs new file mode 100644 index 000000000..ce7b0bd49 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Reading/DocxTextReader.cs @@ -0,0 +1,32 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Reading; +public class DocxTextReader : IFileTextReader +{ + public bool CanRead(string filePath) => + filePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase); + public Result> ReadWords(string filePath) + { + if (!File.Exists(filePath)) + return Result.Fail>($"File not found: {filePath}"); + + return Result.Of(() => + { + using var doc = WordprocessingDocument.Open(filePath, false); + var body = doc.MainDocumentPart?.Document.Body; + if (body == null) + return []; + var words = new List(); + foreach (var line in body.Descendants().Select(t => t.Text)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + words.AddRange(line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + return (IReadOnlyList)words; + }) + .RefineError($"Failed to read docx file: {filePath}"); + } +} diff --git a/TagsCloudContainer.Core/Infrastructure/Reading/ITextReader.cs b/TagsCloudContainer.Core/Infrastructure/Reading/ITextReader.cs new file mode 100644 index 000000000..d58977be2 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Reading/ITextReader.cs @@ -0,0 +1,11 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Reading; +public interface ITextReader +{ + bool CanRead(string filePath); + Result> ReadWords(string filePath); +} + +public interface IFileTextReader : ITextReader +{ } \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Reading/MultiFormatTextReader.cs b/TagsCloudContainer.Core/Infrastructure/Reading/MultiFormatTextReader.cs new file mode 100644 index 000000000..47fe63b1c --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Reading/MultiFormatTextReader.cs @@ -0,0 +1,18 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Reading; +public class MultiFormatTextReader(IEnumerable readers) : ITextReader +{ + private readonly IReadOnlyCollection _readers = readers.ToArray(); + + public bool CanRead(string filePath) => _readers.Any(r => + r.CanRead(filePath)); + + public Result> ReadWords(string filePath) + { + var reader = _readers.FirstOrDefault(r => r.CanRead(filePath)); + return reader == null + ? Result.Fail>($"Unsupported file format: {filePath}") + : reader.ReadWords(filePath).RefineError("Failed to read input file"); + } +} diff --git a/TagsCloudContainer.Core/Infrastructure/Reading/TextFileReader.cs b/TagsCloudContainer.Core/Infrastructure/Reading/TextFileReader.cs new file mode 100644 index 000000000..cba473148 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Reading/TextFileReader.cs @@ -0,0 +1,21 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Infrastructure.Reading; +public class TextFileReader : IFileTextReader +{ + public bool CanRead(string filePath) + { + return filePath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase); + } + + public Result> ReadWords(string filePath) + { + if (!File.Exists(filePath)) + return Result.Fail>($"File not found: {filePath}"); + + return Result.Of>(() => File.ReadLines(filePath) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => line.Trim()).ToArray(), + $"Failed to read file: {filePath}"); + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Rendering/ITagCloudRenderer.cs b/TagsCloudContainer.Core/Infrastructure/Rendering/ITagCloudRenderer.cs new file mode 100644 index 000000000..222b08835 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Rendering/ITagCloudRenderer.cs @@ -0,0 +1,10 @@ +using ResultOf; +using SkiaSharp; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Rendering; +public interface ITagCloudRenderer +{ + Result Render(IEnumerable layoutWords, LayoutOptions options); + Result SaveToFile(SKImage image, string filePath); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Infrastructure/Rendering/PngRenderer.cs b/TagsCloudContainer.Core/Infrastructure/Rendering/PngRenderer.cs new file mode 100644 index 000000000..68a102211 --- /dev/null +++ b/TagsCloudContainer.Core/Infrastructure/Rendering/PngRenderer.cs @@ -0,0 +1,53 @@ +using ResultOf; +using SkiaSharp; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Core.Infrastructure.Rendering; +public class PngRenderer : ITagCloudRenderer +{ + public Result Render(IEnumerable layoutWords, LayoutOptions options) + { + return Result.Of(() => + { + var info = new SKImageInfo(options.Width, options.Height); + using var surface = SKSurface.Create(info); + if (surface == null) + throw new InvalidOperationException("Failed to create Skia surface"); + var canvas = surface.Canvas; + canvas.Clear(options.BackgroundColor); + + foreach (var layoutWord in layoutWords) + { + using var paint = new SKPaint(); + paint.Color = layoutWord.Color; + paint.Typeface = layoutWord.Typeface; + paint.TextSize = layoutWord.FontSize; + paint.IsAntialias = true; + + canvas.DrawText( + layoutWord.WordFrequency.Word, + layoutWord.Rectangle.Left, + layoutWord.Rectangle.Bottom, + paint); + } + return surface.Snapshot(); + }) + .RefineError("Failed to render image"); + } + + public Result SaveToFile(SKImage image, string filePath) + { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) + return Result.Fail($"Output directory doesn't exist {dir}"); + return Result.OfAction(() => + { + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + if (data == null) + throw new InvalidOperationException("Failed to encode image"); + using var stream = File.OpenWrite(filePath); + data.SaveTo(stream); + }) + .RefineError($"Failed to save file {filePath}"); + } +} diff --git a/TagsCloudContainer.Core/Models/LayoutOptions.cs b/TagsCloudContainer.Core/Models/LayoutOptions.cs new file mode 100644 index 000000000..ec04bf84b --- /dev/null +++ b/TagsCloudContainer.Core/Models/LayoutOptions.cs @@ -0,0 +1,11 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Models; +public class LayoutOptions +{ + public int Width { get; init; } = 800; + public int Height { get; init; } = 600; + public string FontFamily { get; init; } = "Arial"; + public SKColor BackgroundColor { get; } = SKColors.White; + public SKPoint Center => new(Width / 2f, Height / 2f); +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Models/LayoutWord.cs b/TagsCloudContainer.Core/Models/LayoutWord.cs new file mode 100644 index 000000000..e0850243d --- /dev/null +++ b/TagsCloudContainer.Core/Models/LayoutWord.cs @@ -0,0 +1,21 @@ +using SkiaSharp; + +namespace TagsCloudContainer.Core.Models; +public class LayoutWord +{ + public WordFrequency WordFrequency { get; } + public SKTypeface Typeface { get; } + public float FontSize { get; } + public SKColor Color { get; } + public SKRect Rectangle { get; } + + public LayoutWord(WordFrequency wordFrequency, SKTypeface typeface, + float fontSize, SKColor color, SKRect rectangle) + { + WordFrequency = wordFrequency; + Typeface = typeface; + FontSize = fontSize; + Color = color; + Rectangle = rectangle; + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Models/TagCloudSettings.cs b/TagsCloudContainer.Core/Models/TagCloudSettings.cs new file mode 100644 index 000000000..7f7fd2b4e --- /dev/null +++ b/TagsCloudContainer.Core/Models/TagCloudSettings.cs @@ -0,0 +1,57 @@ +using ResultOf; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Infrastructure.Layout; + +namespace TagsCloudContainer.Core.Models; +public sealed class TagCloudSettings +{ + public int Width { get; } + public int Height { get; } + public string FontFamily { get; } + public ColorSchemeType ColorScheme { get; } + public TagCloudAlgorithmType Algorithm { get; } + public int MinFontSize { get; } + public int MaxFontSize { get; } + public string? BoringWordsPath { get; } + + private TagCloudSettings( + int width, + int height, + string fontFamily, + ColorSchemeType colorScheme, + TagCloudAlgorithmType algorithm, + int minFontSize, + int maxFontSize, + string? boringWordsPath) + { + Width = width; + Height = height; + FontFamily = fontFamily; + ColorScheme = colorScheme; + Algorithm = algorithm; + MinFontSize = minFontSize; + MaxFontSize = maxFontSize; + BoringWordsPath = boringWordsPath; + } + + public static Result Create(TagCloudSettingsDto? data) + { + if (data == null) + return Result.Fail("Settings are not specified"); + + var boringWordsPath = string.IsNullOrWhiteSpace(data.BoringWordsPath) + ? null + : data.BoringWordsPath; + + var settings = new TagCloudSettings( + data.Width, + data.Height, + data.FontFamily, + data.ColorScheme, + data.Algorithm, + data.MinFontSize, + data.MaxFontSize, + boringWordsPath); + return new TagCloudSettingsValidator().Validate(settings); + } +} diff --git a/TagsCloudContainer.Core/Models/TagCloudSettingsDto.cs b/TagsCloudContainer.Core/Models/TagCloudSettingsDto.cs new file mode 100644 index 000000000..be5657447 --- /dev/null +++ b/TagsCloudContainer.Core/Models/TagCloudSettingsDto.cs @@ -0,0 +1,15 @@ +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Infrastructure.Layout; + +namespace TagsCloudContainer.Core.Models; +public class TagCloudSettingsDto +{ + public int Width { get; set; } = 800; + public int Height { get; set; } = 600; + public string FontFamily { get; set; } = "Arial"; + public ColorSchemeType ColorScheme { get; set; } = ColorSchemeType.Random; + public TagCloudAlgorithmType Algorithm { get; set; } = TagCloudAlgorithmType.Spiral; + public int MinFontSize { get; set; } = 10; + public int MaxFontSize { get; set; } = 50; + public string? BoringWordsPath { get; set; } = string.Empty; +} diff --git a/TagsCloudContainer.Core/Models/TagCloudSettingsValidator.cs b/TagsCloudContainer.Core/Models/TagCloudSettingsValidator.cs new file mode 100644 index 000000000..fbb480ae9 --- /dev/null +++ b/TagsCloudContainer.Core/Models/TagCloudSettingsValidator.cs @@ -0,0 +1,37 @@ +using ResultOf; + +namespace TagsCloudContainer.Core.Models; +public class TagCloudSettingsValidator +{ + private const int MaxImageSide = 10000; + private const int MaxFontSize = 500; + + public Result Validate(TagCloudSettings settings) + { + if (settings.Width <= 0 || settings.Height <= 0) + return Result.Fail("Invalid image size. Width and height must be positive"); + + if (settings.Width > MaxImageSide || settings.Height > MaxImageSide) + return Result.Fail( + $"Invalid image size. Max width/height is {MaxImageSide}"); + + if (settings.MinFontSize <= 0 || settings.MaxFontSize <= 0) + return Result.Fail("Invalid font size. Min/Max must be positive"); + + if (settings.MinFontSize > settings.MaxFontSize) + return Result.Fail("Invalid font size range. MinFontSize > MaxFontSize"); + + if (settings.MaxFontSize > MaxFontSize) + return Result.Fail($"Invalid font size. MaxFontSize exceeds {MaxFontSize}"); + + if (string.IsNullOrWhiteSpace(settings.FontFamily)) + return Result.Fail("Font family is not specified"); + + if (!string.IsNullOrWhiteSpace(settings.BoringWordsPath) && + !File.Exists(settings.BoringWordsPath)) + return Result.Fail( + $"Boring words file not found: {settings.BoringWordsPath}"); + + return Result.Ok(settings); + } +} diff --git a/TagsCloudContainer.Core/Models/WordFrequency.cs b/TagsCloudContainer.Core/Models/WordFrequency.cs new file mode 100644 index 000000000..df02e7b76 --- /dev/null +++ b/TagsCloudContainer.Core/Models/WordFrequency.cs @@ -0,0 +1,13 @@ +namespace TagsCloudContainer.Core.Models; +public class WordFrequency +{ + public string Word { get; } + public int Frequency { get; } + + public WordFrequency(string word, int frequency) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(frequency, 0); + Word = word; + Frequency = frequency; + } +} \ No newline at end of file diff --git a/TagsCloudContainer.Core/Result.cs b/TagsCloudContainer.Core/Result.cs new file mode 100644 index 000000000..9cf6a57f3 --- /dev/null +++ b/TagsCloudContainer.Core/Result.cs @@ -0,0 +1,104 @@ +namespace ResultOf; + public class None + { + private None() + { + } + } + + public struct Result + { + public Result(string? error, T? value = default) + { + Error = error; + Value = value; + } + public static implicit operator Result(T v) + { + return Result.Ok(v); + } + + public string? Error { get; } + internal T? Value { get; } + public T GetValueOrThrow() + { + if (IsSuccess) return Value; + throw new InvalidOperationException($"No value. Only Error {Error}"); + } + public bool IsSuccess => Error == null; + } + + public static class Result + { + public static Result Ok(T? value) + { + return new Result(null, value); + } + + public static Result Ok() + { + return Ok(null); + } + + public static Result Fail(string e) + { + return new Result(e); + } + + public static Result Of(Func f, string? error = null) + { + try + { + return Ok(f()); + } + catch (Exception e) + { + return Fail(error ?? e.Message); + } + } + + public static Result OfAction(Action f, string? error = null) + { + try + { + f(); + return Ok(); + } + catch (Exception e) + { + return Fail(error ?? e.Message); + } + } + + public static Result Then( + this Result input, + Func> continuation) + { + return input.IsSuccess + ? continuation(input.Value) + : Fail(input.Error); + } + + public static Result OnFail( + this Result input, + Action handleError) + { + if (!input.IsSuccess) handleError(input.Error); + return input; + } + + public static Result ReplaceError( + this Result input, + Func replaceError) + { + if (input.IsSuccess) return input; + return Fail(replaceError(input.Error)); + } + + public static Result RefineError( + this Result input, + string errorMessage) + { + return input.ReplaceError(err => errorMessage + ". " + err); + } + } \ No newline at end of file diff --git a/TagsCloudContainer.Core/TagsCloudContainer.Core.csproj b/TagsCloudContainer.Core/TagsCloudContainer.Core.csproj new file mode 100644 index 000000000..1e3ec05d4 --- /dev/null +++ b/TagsCloudContainer.Core/TagsCloudContainer.Core.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/TagsCloudContainer.Tests/PngRendererTests.cs b/TagsCloudContainer.Tests/PngRendererTests.cs new file mode 100644 index 000000000..10e967cda --- /dev/null +++ b/TagsCloudContainer.Tests/PngRendererTests.cs @@ -0,0 +1,46 @@ +using System.IO; +using FluentAssertions; +using NUnit.Framework; +using SkiaSharp; +using TagsCloudContainer.Core.Infrastructure.Rendering; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Tests; + +[TestFixture] +public class PngRendererTests +{ + [Test] + public void SaveToFile_ShouldCreateNonEmptyPng() + { + var renderer = new PngRenderer(); + var options = new LayoutOptions(); + var typeface = SKTypeface.FromFamilyName(options.FontFamily); + + var layoutWords = new[] + { + new LayoutWord(new WordFrequency("hello", 1), typeface, 24, + SKColors.Black, new SKRect(10, 10, 70, 40)), + new LayoutWord(new WordFrequency("world", 1), typeface, 24, + SKColors.Blue, new SKRect(80, 10, 150, 40)) + }; + + var renderResult = renderer.Render(layoutWords, options); + renderResult.IsSuccess.Should().BeTrue(); + using var image = renderResult.GetValueOrThrow(); + var tempPath = Path.GetTempFileName(); + + try + { + renderer.SaveToFile(image, tempPath); + + File.Exists(tempPath).Should().BeTrue(); + new FileInfo(tempPath).Length.Should().BeGreaterThan(0); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } +} diff --git a/TagsCloudContainer.Tests/SpiralTagCloudAlgorithmTests.cs b/TagsCloudContainer.Tests/SpiralTagCloudAlgorithmTests.cs new file mode 100644 index 000000000..ecdf1eb03 --- /dev/null +++ b/TagsCloudContainer.Tests/SpiralTagCloudAlgorithmTests.cs @@ -0,0 +1,95 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NSubstitute; +using SkiaSharp; +using TagsCloudContainer.Core.Infrastructure.Coloring; +using TagsCloudContainer.Core.Infrastructure.Layout; +using TagsCloudContainer.Core.Models; + +namespace TagsCloudContainer.Tests; +[TestFixture] +public class SpiralTagCloudAlgorithmTests +{ + [Test] + public void Arrange_ShouldPlaceAllWordsWithoutIntersections() + { + const float fontSize = 20f; + var fontCalculator = Substitute.For(); + fontCalculator.Calculate(Arg.Any(), Arg.Any()).Returns(fontSize); + + var colorScheme = Substitute.For(); + colorScheme.GetColor(Arg.Any()).Returns(SKColors.Black); + + var algorithm = new SpiralTagCloudAlgorithm(fontCalculator, colorScheme); + var options = new LayoutOptions(); + + var frequencies = new[] + { + new WordFrequency("one", 5), + new WordFrequency("two", 3), + new WordFrequency("three", 1) + }; + + var result = algorithm.Arrange(frequencies, options); + + result.IsSuccess.Should().BeTrue(); + var layoutWords = result.GetValueOrThrow().ToArray(); + + layoutWords.Should().HaveCount(frequencies.Length); + + for (var i = 0; i < layoutWords.Length; i++) + { + for (var j = i + 1; j < layoutWords.Length; j++) + { + layoutWords[i].Rectangle.IntersectsWith(layoutWords[j].Rectangle) + .Should().BeFalse($"rectangles {i} and {j} should not overlap"); + } + } + + } + + [Test] + public void Arrange_ShouldPlaceFirstWordAroundCenter() + { + var fontCalculator = Substitute.For(); + fontCalculator.Calculate(Arg.Any(), Arg.Any()).Returns(18f); + + var colorScheme = Substitute.For(); + colorScheme.GetColor(Arg.Any()).Returns(SKColors.Black); + + var algorithm = new SpiralTagCloudAlgorithm(fontCalculator, colorScheme); + var options = new LayoutOptions(); + + var frequencies = new[] { new WordFrequency("center", 10) }; + + var result = algorithm.Arrange(frequencies, options); + result.IsSuccess.Should().BeTrue(); + var layoutWords = result.GetValueOrThrow().ToArray(); + + layoutWords.Should().ContainSingle(); + layoutWords[0].Rectangle.Contains(options.Center).Should().BeTrue(); + } + + [Test] + public void Arrange_ShouldFail_WhenWordTooLargeForImage() + { + var fontCalculator = Substitute.For(); + fontCalculator + .Calculate(Arg.Any(), Arg.Any()).Returns(1000f); + + var colorScheme = Substitute.For(); + colorScheme.GetColor(Arg.Any()).Returns(SKColors.Black); + + var algorithm = new SpiralTagCloudAlgorithm(fontCalculator, colorScheme); + var options = new LayoutOptions(); + + var frequencies = new[] { new WordFrequency("qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", 10) }; + + var result = algorithm.Arrange(frequencies, options); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("Tag cloud doesn't fit"); + } + +} diff --git a/TagsCloudContainer.Tests/TagsCloudContainer.Tests.csproj b/TagsCloudContainer.Tests/TagsCloudContainer.Tests.csproj new file mode 100644 index 000000000..b4ce3435e --- /dev/null +++ b/TagsCloudContainer.Tests/TagsCloudContainer.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TagsCloudContainer.Tests/TextFileReaderTests.cs b/TagsCloudContainer.Tests/TextFileReaderTests.cs new file mode 100644 index 000000000..9b8e56bdd --- /dev/null +++ b/TagsCloudContainer.Tests/TextFileReaderTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer.Core.Infrastructure.Reading; + +namespace TagsCloudContainer.Tests; +[TestFixture] +public class TextFileReaderTests +{ + private TextFileReader _reader; + + [SetUp] + public void SetUp() => _reader = new TextFileReader(); + + [Test] + public void CanRead_ShouldReturnTrue_ForTxtFiles() + { + _reader.CanRead("test.txt").Should().BeTrue(); + } + + [Test] + public void CanRead_ShouldReturnFalse_ForNonTxtFiles() + { + _reader.CanRead("test.doc").Should().BeFalse(); + } + + [Test] + public void ReadWords_ShouldReturnAllLines_FromExistingFile() + { + using var temp = new TempFile(new[] { "word1", "word2", "word3" }); + + var words = _reader.ReadWords(temp.Path); + + words.IsSuccess.Should().BeTrue(); + words.GetValueOrThrow().Should().Equal("word1", "word2", "word3"); + } + + [Test] + public void ReadWords_ShouldReturnFail_ForNonExistingFile() + { + var result = _reader.ReadWords("non_existing_file.txt"); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("File not found"); + + } + + [Test] + public void ReadWords_ShouldSkipEmptyLines() + { + using var temp = new TempFile(new[] { "word1", "", "word2", " ", "word3" }); + + var words = _reader.ReadWords(temp.Path); + + words.IsSuccess.Should().BeTrue(); + words.GetValueOrThrow().Should().Equal("word1", "word2", "word3"); + + } + + private sealed class TempFile : IDisposable + { + public string Path { get; } + + public TempFile(IEnumerable lines) + { + Path = System.IO.Path.GetTempFileName(); + File.WriteAllLines(Path, lines); + } + public void Dispose() + { + if (File.Exists(Path)) + File.Delete(Path); + } + } +} diff --git a/TagsCloudContainer.Tests/words.txt b/TagsCloudContainer.Tests/words.txt new file mode 100644 index 000000000..266b769b0 --- /dev/null +++ b/TagsCloudContainer.Tests/words.txt @@ -0,0 +1,106 @@ +программирование +программирование +программирование +программирование +программирование +код +код +код +код +алгоритм +алгоритм +алгоритм +тестирование +тестирование +разработка +разработка +система +система +данные +данные +база +база +приложение +приложение +интерфейс +интерфейс +пользователь +пользователь +операционный +компилятор +интерпретатор +функция +метод +класс +объект +наследование +полиморфизм +инкапсуляция +абстракция +библиотека +фреймворк +платформа +сервер +клиент +сеть +протокол +безопасность +шифрование +аутентификация +авторизация +базаданных +запрос +транзакция +индекс +оптимизация +производительность +отладка +деплоймент +контейнеризация +оркестрация +микросервис +монолит +репозиторий +ветка +мерж +конфликт +рефакторинг +документация +комментарий +синтаксис +семантика +память +процессор +жесткий +диск +оперативная +видеокарта +материнская +плата +периферийный +устройство +клавиатура +мышь +монитор +принтер +сканер +наушники +колонки +вебкамера +микрофон +роутер +модем +маршрутизатор +коммутатор +файрвол +антивирус +малвар +вирус +троян +шпионское +по +рекламное +вымогатель +ботнет +фишинг +спам \ No newline at end of file diff --git a/fp.sln b/fp.sln index d104ab530..16ea7b249 100644 --- a/fp.sln +++ b/fp.sln @@ -10,6 +10,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Summator", "Samples\Summato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConwaysGameOfLife", "Samples\ConwaysGameOfLife\ConwaysGameOfLife.csproj", "{4B77EC28-5FB5-4095-B3D7-127F5C488D6E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudContainer.Console", "TagsCloudContainer.Console\TagsCloudContainer.Console.csproj", "{FFDF4921-C18B-41CD-9505-C1DEB93FC8B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudContainer.Core", "TagsCloudContainer.Core\TagsCloudContainer.Core.csproj", "{E4C37F22-CA7E-4C81-A920-79100D2975AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudContainer.Tests", "TagsCloudContainer.Tests\TagsCloudContainer.Tests.csproj", "{FFED1ADE-E30A-4352-A206-2B4DA3396F02}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +38,18 @@ Global {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {FFDF4921-C18B-41CD-9505-C1DEB93FC8B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFDF4921-C18B-41CD-9505-C1DEB93FC8B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFDF4921-C18B-41CD-9505-C1DEB93FC8B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFDF4921-C18B-41CD-9505-C1DEB93FC8B8}.Release|Any CPU.Build.0 = Release|Any CPU + {E4C37F22-CA7E-4C81-A920-79100D2975AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4C37F22-CA7E-4C81-A920-79100D2975AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4C37F22-CA7E-4C81-A920-79100D2975AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4C37F22-CA7E-4C81-A920-79100D2975AE}.Release|Any CPU.Build.0 = Release|Any CPU + {FFED1ADE-E30A-4352-A206-2B4DA3396F02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFED1ADE-E30A-4352-A206-2B4DA3396F02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFED1ADE-E30A-4352-A206-2B4DA3396F02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFED1ADE-E30A-4352-A206-2B4DA3396F02}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {C33F3A5E-A1ED-4657-9B35-968A4CB23AA1} = {754C1CC8-A8B6-46C6-B35C-8A43B80111A0} diff --git a/out.png b/out.png new file mode 100644 index 000000000..f68c7259f Binary files /dev/null and b/out.png differ diff --git a/words.txt b/words.txt new file mode 100644 index 000000000..266b769b0 --- /dev/null +++ b/words.txt @@ -0,0 +1,106 @@ +программирование +программирование +программирование +программирование +программирование +код +код +код +код +алгоритм +алгоритм +алгоритм +тестирование +тестирование +разработка +разработка +система +система +данные +данные +база +база +приложение +приложение +интерфейс +интерфейс +пользователь +пользователь +операционный +компилятор +интерпретатор +функция +метод +класс +объект +наследование +полиморфизм +инкапсуляция +абстракция +библиотека +фреймворк +платформа +сервер +клиент +сеть +протокол +безопасность +шифрование +аутентификация +авторизация +базаданных +запрос +транзакция +индекс +оптимизация +производительность +отладка +деплоймент +контейнеризация +оркестрация +микросервис +монолит +репозиторий +ветка +мерж +конфликт +рефакторинг +документация +комментарий +синтаксис +семантика +память +процессор +жесткий +диск +оперативная +видеокарта +материнская +плата +периферийный +устройство +клавиатура +мышь +монитор +принтер +сканер +наушники +колонки +вебкамера +микрофон +роутер +модем +маршрутизатор +коммутатор +файрвол +антивирус +малвар +вирус +троян +шпионское +по +рекламное +вымогатель +ботнет +фишинг +спам \ No newline at end of file