Compare commits

...

7 Commits
v1.1.0 ... main

15 changed files with 182 additions and 36 deletions

View File

@ -84,6 +84,9 @@
return 0;
}
Logger.Initialize(debug);
var logger = Logger.GetLogger<Program>();
var lintMode = format == "lint";
if(!lintMode)
@ -95,7 +98,7 @@
}
if (!File.Exists(infile))
{
Console.WriteLine(@"Source file {0} not found.", infile);
logger.Error($"Source file {infile} not found.");
return 2;
}
if (string.IsNullOrWhiteSpace(output))
@ -108,28 +111,28 @@
if (!string.IsNullOrWhiteSpace(output) && (Directory.Exists(output) || File.Exists(output)))
{
Console.WriteLine(@"Specified output {0} already exists.", output);
logger.Error($"Specified output {output} already exists.");
return 2;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
if (!Directory.Exists(tempdir))
{
Console.WriteLine(@"Template directory {0} does not exist.", tempdir);
logger.Error($"Template directory {tempdir} does not exist.");
return 2;
}
if (!File.Exists(Path.Combine(tempdir, "index.html")) ||
!File.Exists(Path.Combine(tempdir, "scene.html")) ||
!File.Exists(Path.Combine(tempdir, "styles.css")))
{
Console.WriteLine(
logger.Error(
@"Template directory must contain ""index.html"", ""scene.html"", and ""style.css"" files.");
}
}
if (!string.IsNullOrWhiteSpace(images) && !Directory.Exists(images))
{
Console.WriteLine(@"Images directory {0} does not exist.", images);
logger.Error($"Images directory {images} does not exist.");
return 2;
}
}
@ -140,7 +143,7 @@
if(!lintMode)
{
storyText = File.ReadAllText(infile);
Console.WriteLine(@"Parsing story...");
logger.Log(@"Parsing story...");
}
else
{
@ -149,8 +152,8 @@
var story = parser.ParseStory(storyText);
parser.Warnings.Select(w => w.ToString()).Distinct().ToList().ForEach(s => Console.WriteLine(s));
story.Orphans.ToList().ForEach(o => Console.WriteLine("Warning L{0},1: \"{1}\": Unreachable {2}", o.LineNumber, o.Name, o.Type));
parser.Warnings.Select(w => w.ToString()).Distinct().ToList().ForEach(s => logger.Raw(s));
story.Orphans.ToList().ForEach(o => logger.Raw($"Warning L{o.LineNumber},1: \"{o.Name}\": Unreachable {o.Type}"));
if(!lintMode && parser.Warnings.Count() == 0)
{
@ -164,7 +167,7 @@
case "epub":
if (string.IsNullOrWhiteSpace(author))
{
Console.WriteLine(@"Epub format requires the --author argument.");
logger.Error(@"Epub format requires the --author argument.");
return 1;
}
rend = new EpubRenderer(author, bookid, language);
@ -183,11 +186,11 @@
if (!string.IsNullOrWhiteSpace(images)) rend.ImageDir = images;
Console.WriteLine(@"Rendering story...");
logger.Log(@"Rendering story...");
rend.Render(story, output, debug);
Console.WriteLine(@"Done.");
logger.Log(@"Done.");
}
return 0;
}
@ -196,16 +199,18 @@
private static void ShowHelp()
{
Console.WriteLine(
@"Usage: ficdown.exe
--format (html|epub|lint)
--in ""/path/to/source.md"" (lint reads sdtin)
@"Usage: ficdown
--format (html|epub)
--in ""/path/to/source.md""
[--out ""/path/to/output""]
[--template ""/path/to/template/dir""]
[--images ""/path/to/images/dir""]
[--author ""Author Name""]
[--bookid ""ePub Book ID""]
[--language ""language""]
[--debug]");
[--debug]
or: ficdown --format lint
(reads input from stdin)");
}
}
}

View File

@ -9,6 +9,7 @@
[Fact]
public void CanParseValidStoryFile()
{
Logger.Initialize(true);
var parser = new FicdownParser();
var storyText = File.ReadAllText(Path.Combine(Template.BaseDir, "TestStories", "CloakOfDarkness.md"));
var story = parser.ParseStory(storyText);

View File

@ -12,6 +12,7 @@ namespace Ficdown.Parser
public class FicdownParser
{
private static Logger _logger = Logger.GetLogger<FicdownParser>();
public List<FicdownException> Warnings { get; private set; }
private IBlockHandler _blockHandler;
@ -64,8 +65,11 @@ namespace Ficdown.Parser
public ResolvedStory ParseStory(string storyText)
{
var lines = storyText.Split(new[] {"\n", "\r\n"}, StringSplitOptions.None);
_logger.Debug($"Parsed {lines.Length} lines.");
var blocks = BlockHandler.ExtractBlocks(lines);
_logger.Debug($"Extracted {blocks.Count()} blocks.");
var story = BlockHandler.ParseBlocks(blocks);
_logger.Debug("Finished initial story breakdown.");
// dupe scene sanity check
foreach(var key in story.Scenes.Keys)

View File

@ -12,7 +12,8 @@
<ItemGroup>
<PackageReference Include="Epub4Net" Version="1.2.0" />
<PackageReference Include="Ionic.Zip" Version="1.9.1.8" />
<PackageReference Include="MarkdownSharp" Version="2.0.5" />
<PackageReference Include="Markdig" Version="0.17.1" />
<PackageReference Include="System.Security.Permissions" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.1" />
</ItemGroup>
</Project>

63
Ficdown.Parser/Logger.cs Normal file
View File

@ -0,0 +1,63 @@
namespace Ficdown.Parser
{
using System;
using System.Collections.Generic;
public class Logger
{
private static bool _initialized = false;
private static bool _debug = false;
private static Dictionary<Type, Logger> _cache;
public Type Type { get; private set; }
private Logger(Type type)
{
Type = type;
}
public static void Initialize(bool debug)
{
_debug = debug;
_cache = new Dictionary<Type, Logger>();
_initialized = true;
}
public static Logger GetLogger<T>()
{
var type = typeof(T);
lock(_cache)
{
if(!_cache.ContainsKey(type))
_cache.Add(type, new Logger(type));
}
return _cache[type];
}
private string Decorate(string message)
{
return $"{DateTime.Now.ToString("")} <{Type.Name}> {message}";
}
public void Raw(string message)
{
Console.WriteLine(message);
}
public void Log(string message)
{
Raw(Decorate(message));
}
public void Debug(string message)
{
if(!_debug) return;
Log($"DEBUG: {message}");
}
public void Error(string message, Exception ex = null)
{
Console.Error.WriteLine(Decorate($"ERROR: {message}"));
}
}
}

View File

@ -7,6 +7,8 @@
internal class PageState
{
public StateManager Manager { get; set; }
public Guid Id { get; set; }
public Scene Scene { get; set; }
public State State { get; set; }
@ -21,12 +23,12 @@
public string UniqueHash
{
get { return _uniqueHash ?? (_uniqueHash = StateManager.GetUniqueHash(State, Scene.Key)); }
get { return _uniqueHash ?? (_uniqueHash = Manager.GetUniqueHash(State, Scene.Key)); }
}
public string CompressedHash
{
get { return _compressedHash ?? (_compressedHash = StateManager.GetCompressedHash(this)); }
get { return _compressedHash ?? (_compressedHash = Manager.GetCompressedHash(this)); }
}
}
}
}

View File

@ -10,6 +10,7 @@
internal class StateResolver : IStateResolver
{
private static Logger _logger = Logger.GetLogger<StateResolver>();
private static readonly Random _random = new Random((int) DateTime.Now.Ticks);
private readonly IDictionary<string, string> _pageNames;
private readonly HashSet<string> _usedNames;
@ -25,6 +26,7 @@
public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story)
{
_logger.Debug("Resolving story paths...");
_story = story;
return new ResolvedStory
{

View File

@ -9,6 +9,15 @@
{
private List<FicdownException> _warnings { get; set; }
public static Utilities GetInstance()
{
return new Utilities
{
_warnings = new List<FicdownException>(),
_blockName = string.Empty
};
}
public static Utilities GetInstance(List<FicdownException> warnings, string blockName, int lineNumber)
{
return new Utilities

View File

@ -11,6 +11,7 @@
internal class GameTraverser : IGameTraverser
{
private static Logger _logger = Logger.GetLogger<GameTraverser>();
private StateManager _manager;
private Queue<StateQueueItem> _processingQueue;
private IDictionary<string, PageState> _processed;
@ -59,6 +60,7 @@
_wasRun = true;
// generate comprehensive enumeration
_logger.Debug("Enumerating story scenes...");
var initial = _manager.InitialState;
_processingQueue.Enqueue(new StateQueueItem
@ -66,6 +68,7 @@
Page = initial,
AffectedStates = new List<State> {initial.AffectedState}
});
var interval = 0;
while (_processingQueue.Count > 0)
{
var state = _processingQueue.Dequeue();
@ -74,9 +77,13 @@
_processed.Add(state.Page.UniqueHash, state.Page);
ProcessState(state);
}
if(++interval % 100 == 0 || _processingQueue.Count == 0)
_logger.Debug($"Processed {interval} scenes, {_processingQueue.Count} queued...");
}
// make sure every page gets affected data on every page that it links to
_logger.Debug("Processing scene links...");
foreach (var pageTuple in _processed)
{
foreach (var linkTuple in pageTuple.Value.Links)
@ -95,6 +102,7 @@
}
// compress redundancies
_logger.Debug("Compressing redundant scenes...");
foreach (var row in _processed)
{
if (!_compressed.ContainsKey(row.Value.CompressedHash))

View File

@ -11,10 +11,12 @@
internal class StateManager
{
private static Logger _logger = Logger.GetLogger<StateManager>();
private readonly Story _story;
private readonly Dictionary<string, int> _stateMatrix;
private readonly int _sceneCount;
private readonly int _actionCount;
private BitArray _scenesSeenMask;
private List<FicdownException> _warnings { get; set; }
@ -24,6 +26,18 @@
_story = story;
var allScenes = _story.Scenes.SelectMany(s => s.Value);
_sceneCount = allScenes.Max(s => s.Id);
_scenesSeenMask = new BitArray(_sceneCount);
// figure out which scenes can affect state
var masked = 0;
foreach(var scene in allScenes)
{
if(Utilities.GetInstance().ParseAnchors(scene.RawDescription).Any(a => a.Href.Toggles != null) || RegexLib.BlockQuotes.IsMatch(scene.RawDescription))
{
_scenesSeenMask[scene.Id - 1] = true;
masked++;
}
}
_actionCount = _story.Actions.Count > 0 ? _story.Actions.Max(a => a.Value.Id) : 0;
_stateMatrix = new Dictionary<string, int>();
var state = 0;
@ -40,6 +54,9 @@
{
_stateMatrix.Add(toggle, state++);
}
_logger.Debug($"{_sceneCount} scenes ({masked} can change state).");
_logger.Debug($"{_actionCount} actions.");
_logger.Debug($"{_stateMatrix.Count()} states.");
}
public PageState InitialState
@ -54,6 +71,8 @@
return new PageState
{
Manager = this,
Id = Guid.Empty,
Links = new Dictionary<string, string>(),
State = new State
@ -119,14 +138,14 @@
state.ScenesSeen[sceneId - 1] = true;
}
public static string GetUniqueHash(State state, string sceneKey)
public string GetUniqueHash(State state, string sceneKey)
{
var combined =
new bool[
state.PlayerState.Count + state.ScenesSeen.Count + state.ActionsToShow.Count +
(state.ActionFirstToggles != null ? state.ActionFirstToggles.Count : 0)];
state.PlayerState.CopyTo(combined, 0);
state.ScenesSeen.CopyTo(combined, state.PlayerState.Count);
state.ScenesSeen.And(_scenesSeenMask).CopyTo(combined, state.PlayerState.Count);
state.ActionsToShow.CopyTo(combined, state.PlayerState.Count + state.ScenesSeen.Count);
if (state.ActionFirstToggles != null)
state.ActionFirstToggles.CopyTo(combined,
@ -134,11 +153,15 @@
var ba = new BitArray(combined);
var byteSize = (int)Math.Ceiling(combined.Length / 8.0);
var encoded = new byte[byteSize];
for(var i = 0; i < byteSize; i++)
{
encoded[i] = 0;
}
ba.CopyTo(encoded, 0);
return string.Format("{0}=={1}", sceneKey, Convert.ToBase64String(encoded));
}
public static string GetCompressedHash(PageState page)
public string GetCompressedHash(PageState page)
{
var compressed = new State
{
@ -159,7 +182,7 @@
{
if (ConditionsMatch(scene, playerState) &&
(newScene == null || newScene.Conditions == null ||
scene.Conditions.Count > newScene.Conditions.Count))
(scene.Conditions != null && scene.Conditions.Count > newScene.Conditions.Count)))
{
newScene = scene;
}
@ -187,6 +210,8 @@
{
return new PageState
{
Manager = this,
Id = Guid.NewGuid(),
Links = new Dictionary<string, string>(),
State = new State

View File

@ -89,6 +89,7 @@
public class EpubRenderer : HtmlRenderer
{
private static readonly Logger _logger = Logger.GetLogger<EpubRenderer>();
private readonly string _author;
private readonly string _bookId;
private readonly string _language;
@ -103,6 +104,7 @@
public override void Render(Model.Parser.ResolvedStory story, string outPath, bool debug = false)
{
_logger.Debug("Generating epub...");
var temppath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(temppath);
base.Render(story, temppath, debug);

View File

@ -3,16 +3,15 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MarkdownSharp;
using Markdig;
using Model.Parser;
using Parser;
public class HtmlRenderer : IRenderer
{
private static Logger _logger = Logger.GetLogger<HtmlRenderer>();
private readonly string _language;
protected readonly Markdown Markdown;
public List<FicdownException> Warnings { private get; set; }
public string IndexTemplate { get; set; }
@ -25,7 +24,6 @@
public HtmlRenderer(string language)
{
_language = language;
Markdown = new Markdown();
}
public virtual void Render(ResolvedStory story, string outPath, bool debug = false)
@ -42,11 +40,12 @@
protected void GenerateHtml(ResolvedStory story, string outPath, bool debug)
{
_logger.Debug("Generating HTML...");
var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string>
{
{"Language", _language},
{"Title", story.Name},
{"Description", Markdown.Transform(story.Description)},
{"Description", Markdown.ToHtml(story.Description)},
{"FirstScene", string.Format("{0}.html", story.FirstPage)}
});
@ -72,7 +71,7 @@
{
{"Language", _language},
{"Title", story.Name},
{"Content", Markdown.Transform(content)}
{"Content", Markdown.ToHtml(content)}
});
File.WriteAllText(Path.Combine(outPath, string.Format("{0}.html", page.Name)), scene);

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Rudis Muiznieks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -10,7 +10,7 @@ test:
dotnet test Ficdown.Parser.Tests
publish: clean
rm /tmp/ficdown*
rm -rf /tmp/ficdown*
dotnet publish --self-contained -c Release -r linux-x64 Ficdown.Console
tar -C Ficdown.Console/bin/Release/netcoreapp2.1/linux-x64/publish -cvzf /tmp/ficdown-linux64.tar.gz .
dotnet publish --self-contained -c Release -r win-x64 Ficdown.Console

View File

@ -1,6 +1,6 @@
# Ficdown
Ficdown is a system for building interactive fiction using MarkDown syntax. See [Ficdown.com](http://www.ficdown.com) for more information.
Ficdown is a system for building interactive fiction using MarkDown syntax.
This project contains the core Ficdown library for parsing Ficdown stories, as well as a console application that can be used to generate HTML or epub ebook formats.
@ -10,7 +10,7 @@ Ficdown is written using .NET Core and should run on Windows, Linux, and OSX wit
## Obtaining
If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://github.com/rudism/Ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive.
If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://code.sitosis.com/rudism/ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive.
## Usage
@ -29,8 +29,8 @@ The pre-built releases are self-contained .NET Core deployments, so you should b
Running ficdown.exe without any arguments will produce the following help text:
Usage: ficdown.exe
--format (html|epub)
--in "/path/to/source.md"
--format (html|epub|lint)
--in "/path/to/source.md" (lint reads stdin)
[--out "/path/to/output"]
[--template "/path/to/template/dir"]
[--images "/path/to/images/dir"]
@ -85,6 +85,10 @@ If you pass this option, all of the pages in your story will include output at t
To generate other formats than HTML or epub, you will have to use third party tools. [Calibre](http://www.calibre-ebook.com) is a popular ebook management suite that includes the ability to convert books from almost any format to any other format. Also, Amazon has an official tool called [KindleGen](http://www.amazon.com/gp/feature.html?docId=1000765211) that you can use to convert your epub to a format that can be read on Kindles.
### Interactive Website
## Additional Tools
Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://github.com/rudism/Ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories.
- Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://code.sitosis.com/rudism/ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories.
- [Ficdown-editor](https://byfernanz.github.io/ficdown-editor/) is a web-based GUI for writing Ficdown.
- [Prop](https://github.com/ByFernanz/prop) is a YAML-header style preprocessor for Ficdown