Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions TagsCloudContainer.Console/CommandLineOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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; }

}
42 changes: 42 additions & 0 deletions TagsCloudContainer.Console/ConsoleClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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<None> GenerateTagCloud(
string inputFile,
string outputFile,
LayoutOptions options)
{
System.Console.WriteLine($"Reading words from {inputFile}...");

return textReader.ReadWords(inputFile)
.Then(words => preprocessor.Process(words))

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Поняла про что ты, сам rider мне подсказал как можно сократить)

.Then(words =>
{
if (words.Count == 0)
return Result.Fail<IReadOnlyList<string>>("No words to build a tag cloud");
return Result.Ok(words);
})
.Then(words => analyzer.Analyze(words))
.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");
}
}
135 changes: 135 additions & 0 deletions TagsCloudContainer.Console/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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<CommandLineOptions>(args)
.MapResult(
options =>
{
return LoadSettings(options)
.Then<TagCloudSettings, None>(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<CommandLineOptions>();
builder.RegisterType<ConsoleClient>().SingleInstance();

var container = builder.Build();
return container.Resolve<ConsoleClient>();
}, "Failed to initialize DI container")
.Then<ConsoleClient, None>(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<TagCloudSettings> ValidateOptions(CommandLineOptions o)
{
if (o.Width <= 0 || o.Height <= 0)
return Result.Fail<TagCloudSettings>("Invalid image size. Width and height must be positive");

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вынесла валидацию в Core, в Program теперь только собираю данные и вызываю валидатор, таким образом и от дублирования избавилась

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я немного другое имел ввиду. У тебя все еще нет гарантии того, что данные в настройках будут валидны, т.к. валидация все равно лежит отдельно от заполнения и никто не мешает пропустить этот шаг и сразу заполнить модель. Как можно гарантировать, что данные в настройках, если они там уже лежат, валидны?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я вроде поняла)
В итоге убрала возможность заполнить модель мимо валидации. TagCloudSettings теперь неизменяемый, конструктор у него приватный, а создать экземпляр можно только через метод Create(), который сразу валидирует и возвращает Result


if (o.MinFontSize <= 0 || o.MaxFontSize <= 0)

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Добавила константы для верхних пределов, поставила сейчас на MaxImageSide=10000 и MaxFontSize=500, чтобы не упираться в проблемы с памятью и рендерингом

return Result.Fail<TagCloudSettings>("Invalid font size. Min/Max must be positive");

if (o.MinFontSize > o.MaxFontSize)
return Result.Fail<TagCloudSettings>("Invalid font size range. MinFontSize > MaxFontSize");

if (string.IsNullOrWhiteSpace(o.InputFile))
return Result.Fail<TagCloudSettings>("Input file is not specified");

if (string.IsNullOrWhiteSpace(o.OutputFile))
return Result.Fail<TagCloudSettings>("Output file is not specified");

return Result.Ok(new TagCloudSettings
{
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<TagCloudSettings> LoadSettings(CommandLineOptions o)
{
if (string.IsNullOrWhiteSpace(o.SettingsFile))
return ValidateOptions(o); // old CLI path

if (!File.Exists(o.SettingsFile))
return Result.Fail<TagCloudSettings>($"Settings file not found: {o.SettingsFile}");

return Result.Of(() =>
{
var json = File.ReadAllText(o.SettingsFile);
var settings = JsonSerializer.Deserialize<TagCloudSettings>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (settings == null)
throw new InvalidOperationException("Settings file is empty or invalid.");

This comment was marked as resolved.

Copy link
Copy Markdown
Author

@Krotkaya Krotkaya Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Упустила этот момент, не выведется из-за Result.Of. Переделала

return settings;
}, $"Failed to read settings file: {o.SettingsFile}")
.Then(ValidateSettings)
.Then(settings => Result.Ok(OverrideBoringWords(settings, o.BoringWordsFile)));
}

static Result<TagCloudSettings> ValidateSettings(TagCloudSettings s)

This comment was marked as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Смотри метод ниже

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Выше поправила

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тут, по сути, такая-же ситуация: сначала заполняем, а потом валидируем. При валидировать нас никто не обязывает, из-за чего ответственность за данные, которые попали в кор, ложится на внешнюю систему (несмотря на то, что сама логика описана в коре)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ага, поправила выше, теперь создания без валидации нет

{
if (s.Width <= 0 || s.Height <= 0)

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Как раз вынести в core, сделала это, выше указала

return Result.Fail<TagCloudSettings>("Invalid image size. Width and height must be positive");

if (s.MinFontSize <= 0 || s.MaxFontSize <= 0)
return Result.Fail<TagCloudSettings>("Invalid font size. Min/Max must be positive");

if (s.MinFontSize > s.MaxFontSize)
return Result.Fail<TagCloudSettings>("Invalid font size range. MinFontSize > MaxFontSize");

if (string.IsNullOrWhiteSpace(s.FontFamily))
return Result.Fail<TagCloudSettings>("Font family is not specified");

return Result.Ok(s);
}

static TagCloudSettings OverrideBoringWords(TagCloudSettings s, string? path)
{
if (string.IsNullOrWhiteSpace(path))

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Перенесла проверку существования файла в TagCloudSettingsValidator (Core) и вызываю валидатор в методе LoadSettings , чтобы проверить путь

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не увидел валидации самого BoringWordsPath, при этом тут тоже хочу подчеркнуть, что валидация и заполнение лежат отдельно, из-за чего гарантий вновь нет(

return s;

return new TagCloudSettings
{
Width = s.Width,
Height = s.Height,
FontFamily = s.FontFamily,
ColorScheme = s.ColorScheme,
MinFontSize = s.MinFontSize,
MaxFontSize = s.MaxFontSize,
Algorithm = s.Algorithm,
BoringWordsPath = path
};
}

14 changes: 14 additions & 0 deletions TagsCloudContainer.Console/TagsCloudContainer.Console.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\TagsCloudContainer.Core\TagsCloudContainer.Core.csproj" />
</ItemGroup>

</Project>
Binary file added TagsCloudContainer.Core/.DS_Store
Binary file not shown.
70 changes: 70 additions & 0 deletions TagsCloudContainer.Core/DI/AutofacModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<TagCloudSettings>();

builder.RegisterType<TextFileReader>().As<IFileTextReader>();
builder.RegisterType<DocxTextReader>().As<IFileTextReader>();

builder.RegisterType<MultiFormatTextReader>()
.As<ITextReader>().SingleInstance();

builder.Register<IBoringWordsProvider>(_ =>
string.IsNullOrWhiteSpace(settings.BoringWordsPath)
? new DefaultBoringWordsProvider()
: new FileBoringWordsProvider(settings.BoringWordsPath))
.SingleInstance();

builder.Register<ITextPreprocessor>(c =>
{
var boringWords = c.Resolve<IBoringWordsProvider>();
return new CompositePreprocessor([
new LowerCaseNormalizer(),
new BoringWordsFilter(boringWords)
]);
}).SingleInstance();

builder.RegisterType<WordFrequencyAnalyzer>().As<IWordFrequencyAnalyzer>().SingleInstance();

builder.Register<IFontSizeCalculator>(_
=> new LinearFontSizeCalculator(settings.MinFontSize, settings.MaxFontSize)).SingleInstance();

builder.RegisterType<RandomColorScheme>()
.Keyed<IColorScheme>(ColorSchemeType.Random)
.SingleInstance();

builder.Register(_ => new GradientColorScheme(SKColors.Blue, SKColors.LightBlue))
.Keyed<IColorScheme>(ColorSchemeType.Gradient)
.SingleInstance();

builder.Register<IColorScheme>(ctx =>
ctx.ResolveKeyed<IColorScheme>(settings.ColorScheme))
.SingleInstance();

builder.RegisterType<SpiralTagCloudAlgorithm>()
.Keyed<ITagCloudAlgorithm>(TagCloudAlgorithmType.Spiral)
.SingleInstance();

builder.RegisterType<TightSpiralTagCloudAlgorithm>()
.Keyed<ITagCloudAlgorithm>(TagCloudAlgorithmType.Tight)
.SingleInstance();

builder.Register<ITagCloudAlgorithm>(ctx =>
ctx.ResolveKeyed<ITagCloudAlgorithm>(settings.Algorithm))
.SingleInstance();

builder.RegisterType<PngRenderer>().As<ITagCloudRenderer>().SingleInstance();
}
}
15 changes: 15 additions & 0 deletions TagsCloudContainer.Core/DI/CompositePreprocessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using ResultOf;
using TagsCloudContainer.Core.Infrastructure.Preprocessing;

namespace TagsCloudContainer.Core.DI;
public class CompositePreprocessor(IEnumerable<ITextPreprocessor> preprocessors) : ITextPreprocessor
{
public Result<IReadOnlyList<string>> Process(IEnumerable<string> words)
{
var current = Result.Ok<IReadOnlyList<string>>(words.ToArray());
return preprocessors.Aggregate(current, (current1, preprocessor)
=> current1.Then(preprocessor.Process));
}


}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using ResultOf;
using TagsCloudContainer.Core.Models;

namespace TagsCloudContainer.Core.Infrastructure.Analysis;

public interface IWordFrequencyAnalyzer
{
Result<IReadOnlyList<WordFrequency>> Analyze(IEnumerable<string> words);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using ResultOf;
using TagsCloudContainer.Core.Models;

namespace TagsCloudContainer.Core.Infrastructure.Analysis;
public class WordFrequencyAnalyzer : IWordFrequencyAnalyzer
{
public Result<IReadOnlyList<WordFrequency>> Analyze(IEnumerable<string> words)
{
var frequencies = words
.GroupBy(word => word)
.Select(group
=> new WordFrequency(group.Key, group.Count()))
.OrderByDescending(wf => wf.Frequency).ToArray();
return Result.Ok<IReadOnlyList<WordFrequency>>(frequencies);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TagsCloudContainer.Core.Infrastructure.Coloring;

public enum ColorSchemeType
{
Random,
Gradient
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using SkiaSharp;

namespace TagsCloudContainer.Core.Infrastructure.Coloring;
public interface IColorScheme
{
SKColor GetColor(int seed);
}
Loading