Compare commits

..

7 Commits
v1.1.0 ... main

15 changed files with 180 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@ -12,7 +12,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Epub4Net" Version="1.2.0" /> <PackageReference Include="Epub4Net" Version="1.2.0" />
<PackageReference Include="Ionic.Zip" Version="1.9.1.8" /> <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" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.1" />
</ItemGroup> </ItemGroup>
</Project> </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 internal class PageState
{ {
public StateManager Manager { get; set; }
public Guid Id { get; set; } public Guid Id { get; set; }
public Scene Scene { get; set; } public Scene Scene { get; set; }
public State State { get; set; } public State State { get; set; }
@ -21,12 +23,12 @@
public string UniqueHash public string UniqueHash
{ {
get { return _uniqueHash ?? (_uniqueHash = StateManager.GetUniqueHash(State, Scene.Key)); } get { return _uniqueHash ?? (_uniqueHash = Manager.GetUniqueHash(State, Scene.Key)); }
} }
public string CompressedHash 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 internal class StateResolver : IStateResolver
{ {
private static Logger _logger = Logger.GetLogger<StateResolver>();
private static readonly Random _random = new Random((int) DateTime.Now.Ticks); private static readonly Random _random = new Random((int) DateTime.Now.Ticks);
private readonly IDictionary<string, string> _pageNames; private readonly IDictionary<string, string> _pageNames;
private readonly HashSet<string> _usedNames; private readonly HashSet<string> _usedNames;
@ -25,6 +26,7 @@
public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story) public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story)
{ {
_logger.Debug("Resolving story paths...");
_story = story; _story = story;
return new ResolvedStory return new ResolvedStory
{ {

View File

@ -9,6 +9,15 @@
{ {
private List<FicdownException> _warnings { get; set; } 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) public static Utilities GetInstance(List<FicdownException> warnings, string blockName, int lineNumber)
{ {
return new Utilities return new Utilities

View File

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

View File

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

View File

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

View File

@ -3,16 +3,15 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using MarkdownSharp; using Markdig;
using Model.Parser; using Model.Parser;
using Parser; using Parser;
public class HtmlRenderer : IRenderer public class HtmlRenderer : IRenderer
{ {
private static Logger _logger = Logger.GetLogger<HtmlRenderer>();
private readonly string _language; private readonly string _language;
protected readonly Markdown Markdown;
public List<FicdownException> Warnings { private get; set; } public List<FicdownException> Warnings { private get; set; }
public string IndexTemplate { get; set; } public string IndexTemplate { get; set; }
@ -25,7 +24,6 @@
public HtmlRenderer(string language) public HtmlRenderer(string language)
{ {
_language = language; _language = language;
Markdown = new Markdown();
} }
public virtual void Render(ResolvedStory story, string outPath, bool debug = false) public virtual void Render(ResolvedStory story, string outPath, bool debug = false)
@ -42,11 +40,12 @@
protected void GenerateHtml(ResolvedStory story, string outPath, bool debug) protected void GenerateHtml(ResolvedStory story, string outPath, bool debug)
{ {
_logger.Debug("Generating HTML...");
var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string> var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string>
{ {
{"Language", _language}, {"Language", _language},
{"Title", story.Name}, {"Title", story.Name},
{"Description", Markdown.Transform(story.Description)}, {"Description", Markdown.ToHtml(story.Description)},
{"FirstScene", string.Format("{0}.html", story.FirstPage)} {"FirstScene", string.Format("{0}.html", story.FirstPage)}
}); });
@ -72,7 +71,7 @@
{ {
{"Language", _language}, {"Language", _language},
{"Title", story.Name}, {"Title", story.Name},
{"Content", Markdown.Transform(content)} {"Content", Markdown.ToHtml(content)}
}); });
File.WriteAllText(Path.Combine(outPath, string.Format("{0}.html", page.Name)), scene); 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 dotnet test Ficdown.Parser.Tests
publish: clean publish: clean
rm /tmp/ficdown* rm -rf /tmp/ficdown*
dotnet publish --self-contained -c Release -r linux-x64 Ficdown.Console 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 . 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 dotnet publish --self-contained -c Release -r win-x64 Ficdown.Console

View File

@ -1,6 +1,6 @@
# Ficdown # 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. 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 ## 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 ## Usage
@ -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. 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