ASDoc.pas

File name
C:\Users\Andreas Rejbrand\Documents\Dev\AlgoSim\Client\ASDoc.pas
Date exported
Time exported
Formatting processor
TPascalFormattingProcessor
unit ASDoc;

interface

uses
  Windows, SysUtils, Types, Classes, UITypes, Graphics, Controls, TextEditor,
  Generics.Defaults, Generics.Collections, StdCtrls, ExtCtrls, ClientDefs,
  ASObjects, StrUtils, ASStrFcns;

type
  EDocException = class(EClientException);

  TDocMode = (dmEdit, dmView);

  TTopicInfo = record
    Name: string;
    Metadata: string;
  end;

  TASDoc = record
  strict private
    class var FPath: string;
    class var FFullTextIndex: TArray<TPair<string, string>>;
    class constructor ClassCreate;
    class function GetMetadata: string; static;
  public
    const Version = '1.0';
    class procedure PrepareViewer(AEditor: TTextEditor); static;
    class function Parse(const ASource: TArray<string>;
      ALinks: THyperlinks; AObjects: TObjectDictionary<Integer, TAlgosimObject>):
      TTextFile; static;
    class procedure SaveToFile(const ATopicName: string; AEditor: TTextEditor;
      const AFileName: TFileName); static;
    class procedure LoadFromFile(AEditor: TTextEditor;
      const AFileName: TFileName; out ATopicInfo: TTopicInfo;
      ADocMode: TDocMode); static;
    class function GetTopicName(const AFileName: TFileName): string; static;
    class function GetDocFileName(const ATopic: string;
      ARaiseOnNotFound: Boolean): string; static;
    class function GetDocFileNameForWeb(const ATopic: string;
      ARaiseOnNotFound: Boolean): string; static;
    class function GetTopics: TArray<string>; static;
    class function Search(const ASearchText: string;
      ASearchOptions: TStringSearchOptions = [ssoIgnoreCase]): TArray<string>; static;
    class procedure CompileFullTextIndex; static;
    class procedure CreateHypertextVersion; overload; static;
    class procedure CreateHypertextVersion(const ATopic: string); overload; static;
    class procedure CreateHypertextIndex; static;
    class procedure CreateHypertextGotoPage; static;
    class procedure RequireDoc; static;
    class property Path: string read FPath;
  end;

implementation

uses
  Math, DateUtils, IOUtils, Character, Zip, PngImage, Registry, UnicodeData,
  ASNum, ASKernel, SndPlayer, Gallery, Forms, FrontEndProps, MainForm, ShellAPI,
  Hash, Clipbrd, ASTable;

type
  TString = record
  strict private
  const
    AllocStep = 1024;
  var
    FData: string;
    FActualLength: Integer;
  public
    procedure Append(C: Char); overload;
    procedure Append(const S: string); overload;
    procedure TrimExcess;
    constructor Create(ACapacity: Integer);
    class operator Explicit(const S: TString): string;
    class operator Implicit(const S: string): TString;
  end;

function UrlEncode(const S: string): string;
begin
  var temp := TString.Create(2*S.Length);
  for var i := 1 to S.Length do
    case S[i] of
      'A'..'Z', 'a'..'z', '0'..'9', '-', '_', '.', '~':
        temp.Append(S[i]);
    else
      for var b in TEncoding.UTF8.GetBytes(S[i]) do
        temp.Append('%' + b.ToHexString(2));
    end;
  Result := string(temp);
end;

{ TASDoc }

class constructor TASDoc.ClassCreate;
begin
  with TRegistry.Create do
    try
      RootKey := HKEY_CURRENT_USER;
      if OpenKey('Software\Rejbrand\Algosim', False) and ValueExists('DocPath') then
        FPath := ReadString('DocPath')
      else
        FPath := TPath.Combine(ExtractFilePath(Application.ExeName), 'Doc');
    finally
      Free;
    end;
end;

function ProcessText(const AText: string): string;
var
  i, c: Integer;
begin
  SetLength(Result, AText.Length);
  c := 0;
  for i := 1 to AText.Length do
    if not AText[i].IsInArray(['§', '‹', '›', '`', #13, #10, #9]) then
    begin
      Inc(c);
      Result[c] := AText[i];
    end
    else if AText[i] = #10 then
    begin
      Inc(c);
      Result[c] := #32;
    end;
  SetLength(Result, c);
end;

class procedure TASDoc.CompileFullTextIndex;
var
  Files: TArray<string>;
  fn, Text, Name: string;
  Zip: TZipFile;
  TextBytes, NameBytes, FullTextBytes: TBytes;
  Lines: TList<string>;
  S: string;
begin

  Lines := TList<string>.Create;
  try

    Files := TDirectory.GetFiles(FPath, '*.asdoc');

    for fn in Files do
    begin
      Zip := TZipFile.Create;
      try
        Zip.Open(fn, zmRead);
        try
          if Zip.IndexOf('text.asml') = -1 then
            Continue;
          Zip.Read('text.asml', TextBytes);
          Text := TEncoding.UTF8.GetString(TextBytes);
          if Zip.IndexOf('name.txt') <> -1 then
          begin
            Zip.Read('name.txt', NameBytes);
            Name := TEncoding.UTF8.GetString(NameBytes);
          end
          else
            Name := TPath.GetFileNameWithoutExtension(fn);
          Text := ProcessText(Text);
          Name := ProcessText(Name);
          Lines.Add(Name + #9 + Text);
        finally
          Zip.Close;
        end;
      finally
        Zip.Free;
      end;
    end;

    S := string.Join(#13#10, Lines.ToArray);
    FullTextBytes := TEncoding.UTF8.GetBytes(S);

  finally
    Lines.Free;
  end;

  Zip := TZipFile.Create;
  try
    Zip.Open(TPath.Combine(FPath, 'fulltext.ftx'), zmWrite);
    try
      Zip.Add(FullTextBytes, 'fulltext.txt');
    finally
      Zip.Close;
    end;
  finally
    Zip.Free;
  end;

end;

const
  Template =
    '<!DOCTYPE html>'#13#10 +
    '<html lang="en">'#13#10 +
    '<head>'#13#10 +
    '<title>%TITLE% – Algosim documentation</title>'#13#10 +
    '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />'#13#10 +
    '<meta name="author" content="%AUTHOR%" />'#13#10 +
    '<link rel="stylesheet" type="text/css" href="asdoc.css" />'#13#10 +
    '</head>'#13#10 +
    ''#13#10 +
    '<body>'#13#10 +
    '<header>'#13#10 +
    '%HEADER%'#13#10 +
    '</header>'#13#10 +
    '<main>'#13#10 +
    '%CONTENT%'#13#10 +
    '</main>'#13#10 +
    '<footer>'#13#10 +
    '%FOOTER%'#13#10 +
    '</footer>'#13#10 +
    '</body>'#13#10 +
    '</html>';

function HtmlEscape(const S: string): string; overload;
begin
  Result :=
    S
    .Replace('&', '&amp;') // must (obviously) be first
    .Replace('<', '&lt;')
    .Replace('>', '&gt;')
end;

function HtmlEscape(const C: Char): string; overload;
begin
  case C of
    '<':
      Result := '&lt;';
    '>':
      Result := '&gt;';
    '&':
      Result := '&amp;';
  else
    Result := C;
  end;
end;

function JsStrLitEscape(const S: string): string;
begin
  Result :=
    S
    .Replace('\', '\\') // must (obviously) be first
    .Replace(#0, '\0')
    .Replace('''', '\''')
    .Replace('"', '\"')
    .Replace(#13#10, '\n')
    .Replace(#13, '\n')
    .Replace(#10, '\n')
    .Replace(#9, '\t')
    .Replace('<', '\u003C')
    .Replace('>', '\u003E')
    .Replace('&', '\u0026')
end;

class procedure TASDoc.CreateHypertextGotoPage;
const
  Script =
    'function GotoTopic()'#13#10 +
    '{'#13#10 +
    '  var TopicName = document.getElementById("topic-name").value;'#13#10 +
    '  var TopicNameLC = TopicName.toLowerCase();'#13#10 +
    '  if (TopicName in topics)'#13#10 +
    '  {'#13#10 +
    '    window.location.replace(topics[TopicName]);'#13#10 +
    '    return false;'#13#10 +
    '  }'#13#10 +
    '  for (var topic in topics)'#13#10 +
    '  {'#13#10 +
    '    if (topic.toLowerCase() == TopicNameLC)'#13#10 +
    '    {'#13#10 +
    '      window.location.replace(topics[topic]);'#13#10 +
    '      return false;'#13#10 +
    '    }'#13#10 +
    '  }'#13#10 +
    '  alert("Topic \"" + TopicName + "\" not found.");'#13#10 +
    '  return false;'#13#10 +
    '}';
begin
  var HypertextRoot := TPath.Combine(Path, 'html');
  var HtmlFileName := TPath.Combine(HypertextRoot, 'docgoto.html');
  var LHeader := '';
  if TFile.Exists(TPath.Combine(Path, 'header.inc')) then
    LHeader := TFile.ReadAllText(TPath.Combine(Path, 'header.inc'), TEncoding.UTF8);
  var LFooter := '';
  if TFile.Exists(TPath.Combine(Path, 'footer.inc')) then
    LFooter := TFile.ReadAllText(TPath.Combine(Path, 'footer.inc'), TEncoding.UTF8);
  var MainContents := TList<string>.Create;
  try
    var UrlDict := TDictionary<string, string>.Create;
    try
      MainContents.Add('<div style="margin-top: 4em; margin-bottom: 4em">');
      MainContents.Add(Format('<h1 style="text-align: center;">%s</h1>', [HtmlEscape('Please enter a topic to display:')]));
      MainContents.Add('<form onsubmit="return GotoTopic()" style="text-align: center">');
      MainContents.Add('  <label for="topic-name">Topic:</label>');
      MainContents.Add('  <input list="topics" id="topic-name" name="topic-name" autofocus="" autocomplete="off" required="" />');
      MainContents.Add('  <button>Go</button>');
      MainContents.Add('  <datalist id="topics">');
      for var Topic in GetTopics do
      begin
        if Topic.IsEmpty then
          Continue;
        var LinkURL := UrlEncode(TPath.GetFileNameWithoutExtension(TASDoc.GetDocFileNameForWeb(Topic, True))) + '.html';
        MainContents.Add(Format('    <option>%s</option>', [HtmlEscape(Topic)]));
        UrlDict.Add(Topic, LinkURL);
      end;
      MainContents.Add('  </datalist>');
      MainContents.Add('</form>');
      MainContents.Add('</div>');
      MainContents.Add('<script language="JavaScript">');
      MainContents.Add('<!--');
      MainContents.Add('var topics = {');
      for var p in UrlDict do
        MainContents.Add(Format('"%s": "%s",', [JsStrLitEscape(p.Key), p.Value]));
      MainContents.Add('"Home": "Algosim.html",');
      MainContents.Add('"Documentation index": "docindex.html"');
      MainContents.Add('};');
      MainContents.Add(Script);
      MainContents.Add('//-->');
      MainContents.Add('</script>');
      var LNow := TTimeZone.Local.ToUniversalTime(Now);
      var PrettyDate := FormatDateTime('dddddd', LNow, TFormatSettings.Create('en-GB'));
      var HtmlDate := Format('<time datetime="%s">%s</time>', [DateToISO8601(LNow, True), PrettyDate]);
      var Html := Template
        .Replace('%CONTENT%', string.Join(#13#10, MainContents.ToArray))
        .Replace('%HEADER%', LHeader)
        .Replace('%FOOTER%', LFooter)
        .Replace('%TITLE%', 'Enter a topic')
        .Replace('%AUTHOR%', 'Algosim Documentation Compiler')
        .Replace('%MODIFIED%', HtmlDate);
      TFile.WriteAllText(HtmlFileName, Html, TEncoding.UTF8);
    finally
      UrlDict.Free;
    end;
  finally
    MainContents.Free;
  end;
end;

class procedure TASDoc.CreateHypertextIndex;
begin
  var HypertextRoot := TPath.Combine(Path, 'html');
  var HtmlFileName := TPath.Combine(HypertextRoot, 'docindex.html');
  var LHeader := '';
  if TFile.Exists(TPath.Combine(Path, 'header.inc')) then
    LHeader := TFile.ReadAllText(TPath.Combine(Path, 'header.inc'), TEncoding.UTF8);
  var LFooter := '';
  if TFile.Exists(TPath.Combine(Path, 'footer.inc')) then
    LFooter := TFile.ReadAllText(TPath.Combine(Path, 'footer.inc'), TEncoding.UTF8);
  var MainContents := TList<string>.Create;
  try
    MainContents.Add('<ul class="helpindex">');
    var PrevLetter := #0;
    for var Topic in GetTopics do
    begin
      if Topic.IsEmpty then
        Continue;
      var FirstLetter := Topic[1].ToUpper;
      if not CharInSet(FirstLetter, ['A'..'Z']) then
        FirstLetter := '#';
      if FirstLetter <> PrevLetter then
        MainContents.Add(Format('  <li class="indexheader">%s</li>', [string(FirstLetter)]));
      var LinkURL := UrlEncode(TPath.GetFileNameWithoutExtension(TASDoc.GetDocFileNameForWeb(Topic, True))) + '.html';
      MainContents.Add(Format('  <li><a href="%s">%s</a></li>', [LinkURL, HtmlEscape(Topic)]));
      PrevLetter := FirstLetter;
    end;
    MainContents.Add('</ul>');
    var LNow := TTimeZone.Local.ToUniversalTime(Now);
    var PrettyDate := FormatDateTime('dddddd', LNow, TFormatSettings.Create('en-GB'));
    var HtmlDate := Format('<time datetime="%s">%s</time>', [DateToISO8601(LNow, True), PrettyDate]);
    var Html := Template
      .Replace('%CONTENT%', string.Join(#13#10, MainContents.ToArray))
      .Replace('%HEADER%', LHeader)
      .Replace('%FOOTER%', LFooter)
      .Replace('%TITLE%', 'Help index')
      .Replace('%AUTHOR%', 'Algosim Documentation Compiler')
      .Replace('%MODIFIED%', HtmlDate);
    TFile.WriteAllText(HtmlFileName, Html, TEncoding.UTF8);
  finally
    MainContents.Free;
  end;
end;

class procedure TASDoc.CreateHypertextVersion(const ATopic: string);
var
  DocFileName,
  DocFileNameForWeb: string;
  HypertextRoot: string;
  Zip: TZipFile;
  TextBytes: TBytes;
  TextLines: TArray<string>;
  MetadataBytes: TBytes;
  MetadataLines: TArray<string>;
  HtmlFileName: string;
  Html, MainContent: string;
  MainContentLines: TStringList;
  CurrentParagraph: string;
  InInput, InOutput, InParagraph, InPre,
  InBulletList, InBulletListInner: Boolean;
  InBulletItem, InBulletItemInner: Boolean;
  ExecInput, Generate, Button: Boolean;
  InTable: Boolean;
  TableLastRow: Integer;
  Flags: TDictionary<string, string>;

  function ConsumeID(out ID: string): Boolean;
  begin
    Result := Flags.TryGetValue('id', ID);
    if Result then
      Flags.Remove('id');
  end;

  function MetadataPart(const APartID: string): string;
  begin
    for var i := 0 to High(MetadataLines) do
      if MetadataLines[i].StartsWith(APartID + ':', True) then
        Exit(Copy(MetadataLines[i], APartID.Length + 2).Trim);
    Result := '';
  end;

  function HtmlFormatDateTime(const S: string): string;
    procedure Inv;
    begin
      raise EDocException.CreateFmt('HtmlFormatDateTime: invalid string "%s".', [S]);
    end;
  begin
    // S must have format 'YYYY"-"MM"-"DD" "hh":"nn":"ss"."zzz"Z"'
    var Parts := S.Split([#32]);
    Result := ''; {lack of noreturn}
    if Length(Parts) <> 2 then Inv;
    var DateParts := Parts[0].Split(['-']);
    if Length(DateParts) <> 3 then Inv;
    var TimeParts := Parts[1].Split([':']);
    if Length(TimeParts) <> 3 then Inv;
    var Y, M, D: Integer;
    if not TryStrToInt(DateParts[0], Y) then Inv;
    if not TryStrToInt(DateParts[1], M) then Inv;
    if not TryStrToInt(DateParts[2], D) then Inv;
    var PrettyDate := FormatDateTime('dddddd', EncodeDate(Y, M, D), TFormatSettings.Create('en-GB'));
    Result := Format('<time datetime="%s">%s</time>', [S, PrettyDate]);
  end;

  function TextLevelFmt(const S: string): string;
  var
    Output: TString;
    InLink, InLinkURL, InCode: Boolean;
    Anchor: Integer;
    LinkCaption, LinkURL: string;
    IgnoreNext: Boolean;

    procedure LinkEnd;
    begin
      if not LinkCaption.IsEmpty then
      begin
        if LinkURL.IsEmpty then
          LinkURL := LinkCaption;
        if not IsURL(LinkURL) then
          LinkURL := UrlEncode(TPath.GetFileNameWithoutExtension(TASDoc.GetDocFileNameForWeb(LinkURL, True))) + '.html';
        Output.Append(Format('<a href="%s">%s</a>', [LinkURL, HtmlEscape(LinkCaption)]));
      end;
      InLink := False;
      InLinkURL := False;
      LinkCaption := '';
      LinkURL := '';
    end;

  begin
    Output := TString.Create(Round(1.5 * S.Length));
    InLink := False;
    InLinkURL := False;
    InCode := False;
    Anchor := 0;
    LinkCaption := '';
    LinkURL := '';
    IgnoreNext := False;
    for var i := 1 to S.Length do
    begin
      if IgnoreNext then
        IgnoreNext := False
      else if InLinkURL then
      begin
        if s[i] = '›' then
        begin
          LinkURL := Copy(S, Anchor, i - Anchor);
          LinkEnd;
        end;
      end
      else if InLink then
      begin
        if (S[i] = '›') or ((S[i] = '|') and (i > Anchor)) then
        begin
          LinkCaption := Copy(S, Anchor, i - Anchor);
          if S[i] = '|' then
          begin
            Anchor := Succ(i);
            InLinkURL := True;
          end
          else
            LinkEnd;
        end
      end
      else if s[i] = '‹' then
      begin
        InLink := True;
        Anchor := Succ(i);
        LinkCaption := '';
        LinkURL := '';
      end
      else if S[i] = '`' then
      begin
        if (i < S.Length) and (S[Succ(i)] = '`') then
        begin
          Output.Append(S[i]);
          IgnoreNext := True;
        end
        else
        begin
          InCode := not InCode;
          if InCode then
            Output.Append('<code>')
          else
            Output.Append('</code>')
        end;
      end
      else
        Output.Append(HtmlEscape(S[i]));
    end;
    Result := string(Output);
    if InCode or InLink or InLinkURL then
      raise EDocException.CreateFmt('Syntax error in "%s".', [ATopic]);
    if (Result.Length = 1) and Result[1].IsSymbol then
      Result := Result[1] + #$FE0E;
  end;

  procedure ParagraphEnd;
  begin
    if CurrentParagraph.IsEmpty then Exit;
    MainContentLines.Add(Format('<p>%s</p>', [TextLevelFmt(CurrentParagraph)]));
    InParagraph := False;
    CurrentParagraph := '';
  end;

  procedure PreEnd;
  begin
    MainContentLines.Add(Format('<pre>%s</pre>', [TextLevelFmt(CurrentParagraph)]));
    InPre := False;
    CurrentParagraph := '';
  end;

  procedure BulletParagraphEnd;
  begin
    if CurrentParagraph.IsEmpty then Exit;
    if InBulletListInner then
      MainContentLines.Add(Format('        <p>%s</p>', [TextLevelFmt(CurrentParagraph)]))
    else if InBulletList then
      MainContentLines.Add(Format('    <p>%s</p>', [TextLevelFmt(CurrentParagraph)]));
    CurrentParagraph := '';
  end;

  procedure BulletItemEnd;
  begin
    BulletParagraphEnd;
    if InBulletItemInner then
    begin
      MainContentLines.Add('      </li>');
      InBulletItemInner := False;
    end
    else if InBulletItem then
    begin
      MainContentLines.Add('  </li>');
      InBulletItem := False;
    end;
  end;

  procedure BulletListEnd;
  begin
    BulletItemEnd;
    if InBulletListInner then
    begin
      MainContentLines.Add('    </ul>');
      InBulletListInner := False;
    end
    else if InBulletList then
    begin
      MainContentLines.Add('</ul>');
      InBulletList := False;
    end;
  end;

var
  LKernel: TASKernel;

  procedure RunScript(const AScript: string; ATextLevelParsing: Boolean = False);
  var
    Obj: TAlgosimObject;
    S, L: string;
  begin
    if LKernel = nil then
    begin
      LKernel := TASKernel.Create;
      LKernel.PropStore.AddSubstore(TFrontEndProperties.Create(AlgosimMainForm));
    end;
    Obj := LKernel.Evaluate(AScript);
    if Obj is TAlgosimPixmap then
    begin
      var LImageFileName := Hash.THashBobJenkins.GetHashString(ATopic + AScript) + '.png';
      var LImageFilePath := TPath.Combine(
          TPath.Combine(HypertextRoot, 'images'),
          LImageFileName
        );
      if FileExists(LImageFilePath) then
        raise EDocException.Create('Image file name collision.');
      TAlgosimPixmap(Obj).Value.SaveToFile(LImageFilePath);
      MainContentLines.Add(Format('<p><img src="%s" alt="%s" /></p>', [
        'images/' + LImageFileName,
        HtmlEscape(Obj.ToString).Replace('"', '&quot;')
      ]));
    end
    else if Obj is TAlgosimSound then
    begin
      var LAudioFileName := Hash.THashBobJenkins.GetHashString(ATopic + AScript) + '.wav';
      var LAudioFilePath := TPath.Combine(
          TPath.Combine(HypertextRoot, 'audio'),
          LAudioFileName
        );
      if FileExists(LAudioFilePath) then
        raise EDocException.Create('Audio file name collision.');
      if TAlgosimSound(Obj).Value.BitsPerSample > 16 then
        TAlgosimSound(Obj).Value.ConvertTo(16).SaveToFile(LAudioFilePath)
      else
        TAlgosimSound(Obj).Value.SaveToFile(LAudioFilePath);
      MainContentLines.Add(Format('<p><audio controls src="%s">%s</audio></p>', [
        'audio/' + LAudioFileName,
        HtmlEscape(Obj.ToString).Replace('"', '&quot;')
      ]));
    end
    else if (Obj is TAlgosimTable) and ATextLevelParsing then
    begin
      var tbl := TAlgosimTable(Obj).Value;
      var ID: string;
      if (tbl.Width = 2) and Flags.ContainsKey('dl') then
      begin
        if ConsumeID(ID) then
          MainContentLines.Add(Format('<dl id="%s">', [ID]))
        else
          MainContentLines.Add('<dl>');
        for var y := 0 to tbl.Height - 1 do
        begin
          MainContentLines.Add(Format('  <dt>%s</dt>', [TextLevelFmt(tbl[Point(0, y)])]));
          MainContentLines.Add(Format('  <dd>%s</dd>', [TextLevelFmt(tbl[Point(1, y)])]));
        end;
        MainContentLines.Add('</dl>');
      end
      else
      begin
        if ConsumeID(ID) then
          MainContentLines.Add(Format('<table class="grid" id="%s">', [ID]))
        else
          MainContentLines.Add('<table class="grid">');
        for var y := 0 to tbl.Height - 1 do
        begin
          MainContentLines.Add('  <tr>');
          for var x := 0 to tbl.Width - 1 do
          begin
            MainContentLines.Add(Format('    <td>%s</td>', [TextLevelFmt(tbl[Point(x, y)])]));
          end;
          MainContentLines.Add('  </tr>');
        end;
        MainContentLines.Add('</table>');
      end
    end
    else
    begin
      S := Obj.ExplainedOutput(DefaultFormatOptions);
      if ATextLevelParsing then
        MainContentLines.Add(Format('<p>%s</p>', [TextLevelFmt(S).Replace(#13#10#13#10, '</p><p>')]))
      else
      begin
        MainContentLines.Add('<pre class="output">');
        for L in S.Split([sLineBreak]) do
          MainContentLines.Add(TextLevelFmt(L));
        MainContentLines.Add('</pre>');
      end;
    end;
  end;

  procedure IncludePrerenderedFile(const AScript: string);
  begin
    var LFileName := Hash.THashBobJenkins.GetHashString(ATopic + #13#10 + AScript) + '.wav';
    var LFilePath := TPath.Combine(
        TPath.Combine(HypertextRoot, 'audio'),
        LFileName
      );
    var LSourcePath := TPath.Combine(
        TPath.Combine(TASDoc.FPath, 'prerendered'),
        LFileName
      );
    if FileExists(LFilePath) then
      raise EDocException.Create('Audio file name collision.');
    if FileExists(LSourcePath) then
    begin
      TFile.Copy(LSourcePath, LFilePath);
      MainContentLines.Add(Format('<p><audio controls src="%s">%s</audio></p>', [
        'audio/' + LFileName,
        HtmlEscape('Pre-recorded output of audio.').Replace('"', '&quot;')
      ]));
    end
    else if True then
    begin
      var S := AScript + #13#10#13#10 + LFileName;
      Clipboard.AsText := S;
      MessageBox(0, PChar(S), 'Missing Prerendered File', MB_ICONINFORMATION);
    end
  end;

  procedure ParseTable(ATableStart: Integer);
  const
    TableChars: array[0..10] of Char = '┌─┬┐│├┼┤└┴┘';

    function BorderOnlyRow(AIndex: Integer): Boolean;
    begin
      var S := TextLines[AIndex].Trim;
      for var i := 1 to S.Length do
        if not S[i].IsInArray(TableChars) then
          Exit(False);
      Result := True;
    end;

    function FirstColEmpty(AIndex: Integer): Boolean;
    begin
      Result := True;
      var LWallCount := 0;
      for var i := 1 to TextLines[AIndex].Length do
      begin
        if TextLines[AIndex][i] = '│' then
        begin
          Inc(LWallCount);
          if LWallCount = 2 then
            Exit;
        end
        else if not TextLines[AIndex][i].IsWhiteSpace then
          Exit(False);
      end;
    end;

  var
    LHeaderRow: Boolean;
    LImplicitRows: Boolean;
    LHeaderCol: Boolean;
    LInTableRow: Boolean;
    LRowHasContent: Boolean;
    LRowIndex: Integer;
    Row: TList<string>;
    WallCount, NewWallCount: Integer;
    FloorCount: Integer;
    HasDivider: Boolean;
    RowCaption, ColCaption: string;
    LInCol: Boolean;
    LColIndex: Integer;
    LColStartIdx: Integer;

    procedure WriteRow;
    begin
      MainContentLines.Add('  <tr>');
      for var i := 1 to Row.Count do
      begin
        var LIsHeader :=
          LHeaderRow and (LRowIndex = 1)
            or
          LHeaderCol and (i = 1);
        if LIsHeader then
          if HasDivider and (LRowIndex = 1) and (i = 1) then
            MainContentLines.Add('    <th class="superheader">')
          else
            MainContentLines.Add('    <th>')
        else
          MainContentLines.Add('    <td>');
        if HasDivider and (LRowIndex = 1) and (i = 1) then
          MainContentLines.Add(Format(
            '<div><span class="rows" title="Rows">%s</span><span class="cols" title="Columns">%s</span></div>',
            [RowCaption.Trim, ColCaption.Trim]
          ))
        else
          MainContentLines.Add('      ' + TextLevelFmt(Row[i - 1]).Replace(#13#10, '<br/>'));
        if LIsHeader then
          MainContentLines.Add('    </th>')
        else
          MainContentLines.Add('    </td>')
      end;
      MainContentLines.Add('  </tr>');
      Row.Clear;
    end;

    function LeadingWhitespaceCount(const S: string): Integer;
    begin
      Result := 0;
      while (Result + 1 <= S.Length) and S[Result + 1].IsWhiteSpace do
        Inc(Result);
    end;

  begin

    InTable := True;

    TableLastRow := High(TextLines);

    for var i := ATableStart to High(TextLines) do
      if TextLines[i].IndexOfAny(TableChars) = -1 then
      begin
        TableLastRow := Pred(i);
        Break;
      end;

    begin
      var c := 80;
      for var i := ATableStart to TableLastRow do
        c := Min(c, LeadingWhitespaceCount(TextLines[i]));
      for var i := ATableStart to TableLastRow do
        Delete(TextLines[i], 1, c);
    end;

    WallCount := 0;
    FloorCount := 0;
    HasDivider := False;

    for var i := ATableStart to TableLastRow do
    begin
      NewWallCount := TextLines[i].CountChar('│');
      if NewWallCount > WallCount then
        WallCount := NewWallCount;
      if BorderOnlyRow(i) then
        Inc(FloorCount);
      if (FloorCount <= 1) and not HasDivider then
      begin
        var DPos := Pos('╲', TextLines[i]);
        if DPos <> 0 then
        begin
          var WallsBefore := 0;
          for var j := 1 to DPos - 1 do
          begin
            if TextLines[i][j] = '│' then
              Inc(WallsBefore);
            if WallsBefore > 1 then
              Break;
          end;
          if WallsBefore <= 1 then
            HasDivider := True;
        end;
      end;
    end;

    if HasDivider then
    begin
      var LFloorCount := 0;
      for var i := ATableStart to TableLastRow do
      begin
        if TextLines[i].Contains('─') then
          Inc(LFloorCount);
        if LFloorCount >= 2 then
          Break;
        var DPos := Pos('╲', TextLines[i]);
        var WPos1 := Pos('│', TextLines[i]);
        var WPos2 := Pos('│', TextLines[i], Succ(WPos1));
        if DPos > 0 then
          if InRange(DPos, WPos1, WPos2) then
          begin
            RowCaption := RowCaption + Copy(TextLines[i], Succ(WPos1), DPos - WPos1 - 1);
            ColCaption := ColCaption + Copy(TextLines[i], Succ(DPos), WPos2 - DPos - 1);
          end
          else if DPos < WPos1 then
          begin
            RowCaption := RowCaption + Copy(TextLines[i], 1, DPos - 1);
            ColCaption := ColCaption + Copy(TextLines[i], Succ(DPos), WPos1 - DPos - 1);
          end;
      end;
    end;

    LHeaderRow := FloorCount = 3;
    LImplicitRows := (FloorCount = 3) and (WallCount >= 3);
    LHeaderCol := (WallCount >= 3) and HasDivider;
    LInTableRow := False;
    LRowHasContent := False;
    LRowIndex := 0;

    var BorderOnlyRowCount := 0;
    var LAfterHeader := False;

    Row := TList<string>.Create;
    try

      MainContentLines.Add('<table>');
      for var i := ATableStart to TableLastRow do
      begin

        var LBorderOnlyRow := BorderOnlyRow(i);
        if LBorderOnlyRow then
          Inc(BorderOnlyRowCount);
        if BorderOnlyRowCount >= 2 then
          LAfterHeader := True;

        if
          LInTableRow and LRowHasContent and LBorderOnlyRow
            or
          LInTableRow and LRowHasContent and LAfterHeader and LImplicitRows and not FirstColEmpty(i)
        then
        begin
          WriteRow;
          LInTableRow := False;
          LRowHasContent := False;
        end;

        if LBorderOnlyRow then
          Continue;

        if not LInTableRow then
        begin
          Row.Clear;
          LInTableRow := True;
          LRowHasContent := False;
          Inc(LRowIndex);
        end;

        LInCol := False;
        LColIndex := 0;
        LColStartIdx := 0;
        for var j := 1 to TextLines[i].Length do
        begin

          if (TextLines[i][j] = '│') and LInCol then
          begin
            var S: string;
            S := Copy(TextLines[i], LColStartIdx, j - LColStartIdx).Trim;
            if LColIndex - 1 <= Row.Count - 1 then
              if Row[LColIndex - 1].Trim.IsEmpty or S.IsEmpty then
                Row[LColIndex - 1] := Row[LColIndex - 1] + S
              else
                Row[LColIndex - 1] := Row[LColIndex - 1] + #13#10 + S
            else
              Row.Add(S);
            LInCol := False;
          end;

          if not TextLines[i][j].IsInArray(TableChars) then
          begin

            if not LInCol then
            begin
              LInCol := True;
              Inc(LColIndex);
              LColStartIdx := j;
            end;

            if not TextLines[i][j].IsWhiteSpace then
              LRowHasContent := True;

          end;

        end;

        if LInCol then
        begin
          var S: string;
          S := Copy(TextLines[i], LColStartIdx).Trim;
          if LColIndex - 1 <= Row.Count - 1 then
            Row[LColIndex - 1] := Row[LColIndex - 1] + S
          else
            Row.Add(S);
        end;

      end;
      if LInTableRow then
        WriteRow;
      MainContentLines.Add('</table>');

    finally
      Row.Free;
    end;

  end;

begin

  var LImgCounter := 0;

  DocFileName := GetDocFileName(ATopic, True);
  DocFileNameForWeb := GetDocFileNameForWeb(ATopic, True);
  HypertextRoot := TPath.Combine(Path, 'html');
  HtmlFileName := TPath.Combine(HypertextRoot, DocFileNameForWeb);

  ForceDirectories(HypertextRoot);
  ForceDirectories(TPath.Combine(HypertextRoot, 'images'));
  ForceDirectories(TPath.Combine(HypertextRoot, 'audio'));

  Flags := TDictionary<string, string>.Create;
  try

    LKernel := nil;
    try

      Zip := TZipFile.Create;
      try
        Zip.Open(DocFileName, zmRead);
        try
          Zip.Read('text.asml', TextBytes);
          TextLines := TEncoding.UTF8.GetString(TextBytes).Split([sLineBreak]);
          Zip.Read('metadata.txt', MetadataBytes);
          MetadataLines := TEncoding.UTF8.GetString(MetadataBytes).Split([sLineBreak]);
          MainContentLines := TStringList.Create;
          try
            InInput := False;
            InOutput := False;
            InParagraph := False;
            InPre := False;
            InBulletList := False;
            InBulletListInner := False;
            InBulletItem := False;
            InBulletItemInner := False;
            ExecInput := False;
            for var i := 0 to High(TextLines) do
            begin
              if TextLines[i].StartsWith('~html') then
              begin
                MainContentLines.Add(Copy(TextLines[i], 6).Trim);
                Continue;
              end;
              if TextLines[i].StartsWith('~set') then
              begin
                var EqPos := Pos('=', TextLines[i]);
                if EqPos = 0 then
                  Flags.AddOrSetValue(Copy(TextLines[i], 5).Trim, '')
                else
                  Flags.AddOrSetValue(Copy(TextLines[i], 6, EqPos - 6).Trim, Copy(TextLines[i], EqPos + 1).Trim);
                Continue;
              end;
              if TextLines[i].StartsWith('~clear') then
              begin
                Flags.Remove(Copy(TextLines[i], 5).Trim);
                Continue;
              end;
              if TextLines[i].StartsWith('~img') then
                Continue;
              if InTable and (i <= TableLastRow) then
                Continue;
              if TextLines[i].StartsWith('  ‣ is an lvalue') then
                TextLines[i] := 'The returned value is an lvalue' + Copy(TextLines[i], 17) + '.';
              if InParagraph then
              begin
                if TextLines[i].Trim.IsEmpty or TextLines[i][1].IsInArray(['<', '>', '!', '§', #$FFFC, '━']) or TextLines[i].StartsWith(#32#32) then
                  ParagraphEnd
                else
                  CurrentParagraph := CurrentParagraph + IfThen(not CurrentParagraph.EndsWith('-'), #32) + TextLines[i].Trim
              end;
              if InPre then
              begin
                if not TextLines[i].StartsWith(#32#32) then
                  PreEnd
                else
                  CurrentParagraph := CurrentParagraph + #13#10 + Copy(TextLines[i], 3).TrimRight;
              end;
              if InBulletListInner then
              begin
                if TextLines[i].StartsWith('  -') then
                  BulletItemEnd
                else if TextLines[i].StartsWith('    ') and not TextLines[i].Trim.IsEmpty then
                  CurrentParagraph := CurrentParagraph + IfThen(not CurrentParagraph.EndsWith('-'), #32) + TextLines[i].Trim
                else if TextLines[i].Trim.IsEmpty and (i < High(TextLines)) and TextLines[Succ(i)].StartsWith('    ') then
                  BulletParagraphEnd
                else if TextLines[i].StartsWith('•') or TextLines[i].Trim.IsEmpty and (i < High(TextLines)) and TextLines[Succ(i)].StartsWith('•') then
                begin
                  BulletListEnd;
                  BulletItemEnd;
                end
                else
                begin
                  BulletListEnd;
                  BulletListEnd;
                end;
              end
              else if InBulletList then
              begin
                if TextLines[i].StartsWith('•') then
                  BulletItemEnd
                else if TextLines[i].StartsWith('  -') then
                  BulletParagraphEnd
                else if TextLines[i].StartsWith('  ') and not TextLines[i].Trim.IsEmpty then
                  CurrentParagraph := CurrentParagraph + IfThen(not CurrentParagraph.EndsWith('-'), #32) + TextLines[i].Trim
                else if TextLines[i].Trim.IsEmpty and (i < High(TextLines)) and TextLines[Succ(i)].StartsWith('  ') then
                  BulletParagraphEnd
                else if TextLines[i].Trim.IsEmpty and (i < High(TextLines)) and TextLines[Succ(i)].StartsWith('•') then
                  {}
                else
                  BulletListEnd
              end;
              {*}
              if InInput then
              begin
                if TextLines[i].StartsWith('>>') then
                begin
                  MainContentLines.Add('</pre>');
                  InInput := False;
                end
                else
                  MainContentLines.Add(TextLines[i].TrimRight);
              end
              else if InOutput then
              begin
                if TextLines[i].StartsWith('<<') then
                begin
                  MainContentLines.Add('</pre>');
                  InOutput := False;
                end
                else
                  MainContentLines.Add(TextLines[i].TrimRight);
              end
              else if ExecInput then
              begin
                if TextLines[i].StartsWith('>>') then
                begin
                  if not Generate then
                  begin
                    MainContentLines.Add('<pre class="input">');
                    for var ln in CurrentParagraph.Split([#13#10]) do
                      MainContentLines.Add(HtmlEscape(ln));
                    MainContentLines.Add('</pre>');
                  end;
                  if Button then
                    IncludePrerenderedFile(CurrentParagraph)
                  else
                    RunScript(CurrentParagraph, Generate);
                  ExecInput := False;
                  Generate := False;
                  Button := False;
                end
                else
                  if CurrentParagraph.IsEmpty then
                    CurrentParagraph := TextLines[i]
                  else
                    CurrentParagraph := CurrentParagraph + #13#10 + TextLines[i];
              end
              else
              begin
                var hn := 0;
                while (hn < TextLines[i].Length) and (TextLines[i][hn + 1] = '§') and (hn < 6) do
                  Inc(hn);
                if hn > 0 then
                begin
                  MainContentLines.Add('');
                  MainContentLines.Add(Format('<h%d>%s</h%d>', [hn, TextLevelFmt(Copy(TextLines[i], hn + 1).Trim), hn]))
                end
                else if TextLines[i].StartsWith('>>@') then
                begin
                  ExecInput := True;
                  Generate := True;
                  Button := False;
                  CurrentParagraph := '';
                end
                else if TextLines[i].StartsWith('>>>') then
                begin
                  ExecInput := True;
                  Generate := False;
                  Button := False;
                  CurrentParagraph := '';
                end
                else if TextLines[i].StartsWith('>>!') then
                begin
                  ExecInput := True;
                  Generate := False;
                  Button := True;
                  CurrentParagraph := '';
                end
                else if TextLines[i].StartsWith('>>') then
                begin
                  MainContentLines.Add('<pre class="input">');
                  InInput := True;
                end
                else if TextLines[i].StartsWith('<<') then
                begin
                  MainContentLines.Add('<pre class="output">');
                  InOutput := True;
                end
                else if TextLines[i].StartsWith('>') then
                  MainContentLines.Add(Format('<pre class="input">%s</pre>', [HtmlEscape(Copy(TextLines[i], 2).Trim)]))
                else if TextLines[i].StartsWith('<') then
                  MainContentLines.Add(Format('<pre class="output">%s</pre>', [HtmlEscape(Copy(TextLines[i], 2).Trim)]))
                else if TextLines[i].StartsWith('!') then
                  MainContentLines.Add(Format('<pre class="error">%s</pre>', [HtmlEscape(Copy(TextLines[i], 2).Trim)]))
                else if TextLines[i].StartsWith('•') then
                begin
                  if not InBulletList then
                  begin
                    MainContentLines.Add('<ul>');
                    InBulletList := True;
                  end;
                  MainContentLines.Add('  <li>');
                  InBulletItem := True;
                  CurrentParagraph := Copy(TextLines[i], 2).Trim;
                end
                else if InBulletList and TextLines[i].StartsWith('  -') then
                begin
                  if not InBulletListInner then
                  begin
                    MainContentLines.Add('    <ul>');
                    InBulletListInner := True;
                  end;
                  MainContentLines.Add('      <li>');
                  InBulletItemInner := True;
                  CurrentParagraph := Copy(TextLines[i], 4).Trim;
                end
                else if TextLines[i].StartsWith(#$FFFC#$FFFF) then
                begin
                  if (i < High(TextLines)) and TextLines[Succ(i)].StartsWith('~img') then
                  begin
                    var LImgSpec := TextLines[Succ(i)].Split([#32]);
                    if Length(LImgSpec) < 2 then
                      raise EDocException.Create('Incomplete ~img directive.');
                    var LImgFileName := LImgSpec[1];
                    var LImgSourcePath := TPath.Combine(TPath.Combine(TASDoc.FPath, 'prerendered'), LImgFileName + '.svg');
                    if not FileExists(LImgSourcePath) then
                      raise EDocException.CreateFmt('Missing SVG image file: %s', [LImgFileName]);
                    var LImgDestPath := TPath.Combine(TPath.Combine(HypertextRoot, 'images'), LImgFileName + '.svg');
                    TFile.Copy(LImgSourcePath, LImgDestPath, True);
                    Inc(LImgCounter);
                    MainContentLines.Add(Format('<p><img src="images/%s" alt="Image %d" /></p>', [LImgFileName + '.svg', LImgCounter]));
                  end
                  else
                  begin
                    var ObjIdx := StrToInt(Copy(TextLines[i], 3).Trim);
                    var LImageBytes: TBytes;
                    var LImageFileName := Hash.THashBobJenkins.GetHashString(ATopic + ObjIdx.ToString) + '.png';
                    var LImageFilePath := TPath.Combine(
                        TPath.Combine(HypertextRoot, 'images'),
                        LImageFileName
                      );
                    if FileExists(LImageFilePath) then
                      raise EDocException.Create('Image file name collision.');
                    Zip.Read('image' + ObjIdx.ToString + '.png', LImageBytes);
                    TFile.WriteAllBytes(LImageFilePath, LImageBytes);
                    Inc(LImgCounter);
                    MainContentLines.Add(Format('<p><img src="images/%s" alt="Image %d" /></p>', [LImageFileName, LImgCounter]));
                  end;
                end
                else if TextLines[i] = StringOfChar('━', 80) then
                  MainContentLines.Add('<hr/>')
                else if TextLines[i].Contains('─') then
                  ParseTable(i)
                else if not InPre and not InParagraph and not InBulletList and TextLines[i].StartsWith(#32#32) and not TextLines[i].Trim.IsEmpty then
                begin
                  InPre := True;
                  CurrentParagraph := Copy(TextLines[i], 3).TrimRight;
                end
                else if not InPre and not InParagraph and not InBulletList and not TextLines[i].Trim.IsEmpty then
                begin
                  InParagraph := True;
                  CurrentParagraph := TextLines[i].Trim;
                end;
              end;
            end;
            if InParagraph then
              ParagraphEnd;
            if InPre then
              PreEnd;
            if InBulletListInner then
              MainContentLines.Add('  </ul>');
            if InBulletList then
              BulletListEnd;
            if not Flags.ContainsKey('noarticle') then
            begin
              MainContentLines.Insert(0, '<article>');
              MainContentLines.Add('</article>');
            end;
            MainContent := MainContentLines.Text;
          finally
            MainContentLines.Free;
          end;
          var LHeader := '';
          if TFile.Exists(TPath.Combine(Path, 'header.inc')) then
            LHeader := TFile.ReadAllText(TPath.Combine(Path, 'header.inc'), TEncoding.UTF8);
          var LFooter := '';
          if TFile.Exists(TPath.Combine(Path, 'footer.inc')) then
            LFooter := TFile.ReadAllText(TPath.Combine(Path, 'footer.inc'), TEncoding.UTF8);
          Html := Template
            .Replace('%CONTENT%', MainContent)
            .Replace('%HEADER%', LHeader)
            .Replace('%FOOTER%', LFooter)
            .Replace('%TITLE%', ATopic)
            .Replace('%AUTHOR%', MetadataPart('Author'))
            .Replace('%MODIFIED%', HtmlFormatDateTime(MetadataPart('Modified')));
          TFile.WriteAllText(HtmlFileName, Html, TEncoding.UTF8);
        finally
          Zip.Close;
        end;
      finally
        Zip.Free;
      end

    finally
      LKernel.Free;
    end;

  finally
    Flags.Free;
  end;

end;

procedure DeleteDirectory(const ADir: string);
var
  FileOp: TSHFileOpStruct;
begin
  FillChar(FileOp, SizeOf(FileOp), 0);
  FileOp.wFunc := FO_DELETE;
  FileOp.pFrom := PChar(ADir + #0);
  FileOp.fFlags := FOF_NOCONFIRMATION;
  SHFileOperation(FileOp);
end;

class procedure TASDoc.CreateHypertextVersion;
begin
  var htmlp := TPath.Combine(Path, 'html');
  AllocConsole;
  try
    DeleteDirectory(htmlp);
    if DirectoryExists(htmlp) then
      raise EDocException.Create('Target not clean.');
    try
      for var Topic in GetTopics do
      begin
        Writeln(Topic);
        CreateHypertextVersion(Topic);
      end;
      TFile.Copy(TPath.Combine(Path, 'asdoc.css'), TPath.Combine(htmlp, 'asdoc.css'), True);
      CreateHypertextIndex;
      CreateHypertextGotoPage;
    except
      DeleteDirectory(htmlp);
      raise;
    end;
  finally
    FreeConsole;
  end;
end;

class function TASDoc.GetDocFileName(const ATopic: string;
  ARaiseOnNotFound: Boolean): string;
var
  i: Integer;
begin

  Result := SanitizeFileName(ATopic);
  if Result.IsEmpty and (ATopic.Length = 1) then
    Result := SanitizeFileName(UCD.GetChrName(ATopic[1]));
  if Result.IsEmpty then
  begin
    Result := '';
    for i := 1 to ATopic.Length do
      Result := Result + Ord(ATopic[i]).ToHexString;
  end;
  if Result.IsEmpty then
    raise EDocException.CreateFmt('Couldn''t create a file name for "%s".', [ATopic]);

  Result := TPath.Combine(Path, Result + '.asdoc');

  if ARaiseOnNotFound and not FileExists(Result) then
    raise EDocException.CreateFmt('Topic "%s" not found.', [ATopic]);

end;

class function TASDoc.GetDocFileNameForWeb(const ATopic: string;
  ARaiseOnNotFound: Boolean): string;
begin
  Result := TPath.GetFileNameWithoutExtension(GetDocFileName(ATopic, ARaiseOnNotFound)) + '.html';
end;

class function TASDoc.GetMetadata: string;
var
  Size: Cardinal;
  UserName: string;
begin
  UserName := '';
  if not GetUserName(nil, Size) and (GetLastError = ERROR_INSUFFICIENT_BUFFER) then
  begin
    SetLength(UserName, Size);
    if GetUserName(PChar(UserName), Size) and (Size > 0) then
      SetLength(UserName, Size - 1);
  end
  else // extremely pedantic
  begin
    Size := 256 + 1;
    SetLength(UserName, Size);
    if GetUserName(PChar(UserName), Size) and (Size > 0) then
      SetLength(UserName, Size - 1);
  end;
  Result :=
    'Author: ' + UserName + sLineBreak +
    'Modified: ' + FormatDateTime('YYYY"-"MM"-"DD" "hh":"nn":"ss"."zzz"Z"',
      TTimeZone.Local.ToUniversalTime(Now),
      TFormatSettings.Invariant) + sLineBreak +
    'Version: ' + Version;
end;

class function TASDoc.GetTopicName(const AFileName: TFileName): string;
var
  Zip: TZipFile;
  NameBytes: TBytes;
begin

  Zip := TZipFile.Create;
  try
    Zip.Open(AFileName, zmRead);
    try
      if Zip.IndexOf('name.txt') = -1 then
        Exit(TPath.GetFileNameWithoutExtension(AFileName));
      Zip.Read('name.txt', NameBytes);
      Result := TEncoding.UTF8.GetString(NameBytes);
    finally
      Zip.Close;
    end;
  finally
    Zip.Free;
  end;

end;

class function TASDoc.GetTopics: TArray<string>;
var
  TopicList: TList<string>;
  s: string;
begin

  RequireDoc;

  TopicList := TList<string>.Create;
  try
    for s in TFile.ReadAllLines(TPath.Combine(FPath, 'index.rdx'), TEncoding.UTF8) do
      TopicList.Add(Copy(s, Succ(Pos(#9, s))));
    TopicList.Sort(TComparer<string>.Construct(
      function(const ALeft, ARight: string): Integer
        function IsSymbol(const S: string): Boolean;
        begin
          Result := (S.Length = 1) and (S[1].IsSymbol or S[1].IsPunctuation);
        end;
      begin
        Result := CompareValue(Ord(IsSymbol(ALeft)), Ord(IsSymbol(ARight)));
        if Result = 0 then
          Result := CompareText(ALeft, ARight);
      end
    ));
    Result := TopicList.ToArray;
  finally
    TopicList.Free;
  end;

end;

type
  TScriptButton = class(TButton)
  public
    Script: string;
    procedure Click; override;
  end;

{ TScriptButton }

procedure TScriptButton.Click;
var
  LKernel: TASKernel;
  LObj: TAlgosimObject;
begin
  inherited;
  if Script.Trim.IsEmpty then
    Exit;
  Screen.Cursor := crHourglass;
  try
    LKernel := TASKernel.Create;
    try
      LObj := LKernel.Evaluate(Script);
      if Assigned(LObj) and not (LObj is TAlgosimNullObject) then
        TGallery.CreateFrame(LObj, '', True);
    finally
      LKernel.Free;
    end;
  finally
    Screen.Cursor := crDefault;
  end;
end;

class procedure TASDoc.LoadFromFile(AEditor: TTextEditor;
  const AFileName: TFileName; out ATopicInfo: TTopicInfo; ADocMode: TDocMode);
resourcestring
  SRunScriptBtnCaption = 'Run';
var
  Zip: TZipFile;
  NameBytes: TBytes;
  MetadataBytes: TBytes;
  TextBytes: TBytes;
  i: Integer;
  CtlId: Integer;
  ImgStream: TStream;
  LocalHeader: TZipHeader;
  Png: TPngImage;
  Bm: TBitmap;
  Links: THyperlinks;
  Objs: TObjectDictionary<Integer, TAlgosimObject>;
  ObjID: Integer;
  Obj: TAlgosimObject;
  SoundPlayer: TSoundPlayer;
  Button: TScriptButton;
begin

  if AEditor = nil then
    Exit;

  AEditor.TextFile.StrictReadOnly := False;
  AEditor.EditMode := emText;
  AEditor.NewFile;

  Links := THyperlinks.Create;
  try

    Objs := TObjectDictionary<Integer, TAlgosimObject>.Create([doOwnsValues]);
    try

      Zip := TZipFile.Create;
      try
        Zip.Open(AFileName, zmRead);
        try
          if Zip.IndexOf('text.asml') = -1 then
            raise EDocException.Create('Invalid documentation file.');
          Zip.Read('text.asml', TextBytes);
          case ADocMode of
            dmEdit:
              AEditor.PlainText := TEncoding.UTF8.GetString(TextBytes);
            dmView:
              AEditor.TextFile := Parse(TEncoding.UTF8.GetString(TextBytes).Split([sLineBreak]),
                Links, Objs);
          else
            raise EDocException.CreateFmt('Invalid document mode: %d', [Ord(ADocMode)]);
          end;
          for i := 0 to AEditor.LineCount - 1 do
          begin
            if AEditor.Lines[i].StartsWith(LINE_CONTROL_PREFIX) then
            begin
              AEditor.LineClasses[i] := LINE_CONTROL_CLASS;
              CtlId := AEditor.GetControlIDFromLine(i);
              if Zip.IndexOf(Format('image%d.png', [CtlId])) = -1 then
              begin
                AEditor.LineClasses[i] := '';
                AEditor.Lines[i] := '[Missing image.]';
              end
              else
              begin
                Zip.Read(Format('image%d.png', [CtlId]), ImgStream, LocalHeader);
                try
                  Png := TPngImage.Create;
                  try
                    Png.LoadFromStream(ImgStream);
                    AEditor.ReceiveGraphic(Png, i);
                  finally
                    Png.Free;
                  end;
                finally
                  ImgStream.Free;
                end;
              end;
            end
            else if AEditor.Lines[i].StartsWith('<~<') then
            begin
              if TryStrToInt(Copy(AEditor.Lines[i], 4), ObjID) and Objs.TryGetValue(ObjID, Obj) then
              begin
                if Obj is TAlgosimPixmap then
                begin
                  AEditor.Lines[i] := '';
                  Bm := TAlgosimPixmap(Obj).Value.CreateGDIBitmap;
                  try
                    AEditor.ReceiveGraphic(Bm, i, True);
                  finally
                    Bm.Free;
                  end;
                end
                else if Obj is TAlgosimSound then
                begin
                  AEditor.Lines[i] := '';
                  SoundPlayer := TSoundPlayer.Create(AEditor);
                  SoundPlayer.Sound := TAlgosimSound(Obj).Value;
                  SoundPlayer.Height := SoundPlayer.PreferredHeight;
                  SoundPlayer.Width := 10 * SoundPlayer.PreferredHeight;
                  AEditor.ReceiveLineControl(SoundPlayer, i, True);
                end;
              end
              else
                AEditor.Lines[i] := '[Missing object.]';
            end
            else if AEditor.Lines[i].StartsWith('<!<') then
            begin
              Button := TScriptButton.Create(AEditor);
              Button.Caption := SRunScriptBtnCaption;
              Button.Script := Copy(AEditor.Lines[i], 4);
              AEditor.ReceiveLineControl(Button, i, True);
            end;
          end;
          if Zip.IndexOf('name.txt') = -1 then
            ATopicInfo.Name := TPath.GetFileNameWithoutExtension(AFileName)
          else
          begin
            Zip.Read('name.txt', NameBytes);
            ATopicInfo.Name := TEncoding.UTF8.GetString(NameBytes);
          end;
          if Zip.IndexOf('metadata.txt') = -1 then
            ATopicInfo.Metadata := ''
          else
          begin
            Zip.Read('metadata.txt', MetadataBytes);
            ATopicInfo.Metadata := TEncoding.UTF8.GetString(MetadataBytes);
          end;
        finally
          Zip.Close;
        end;
      finally
        Zip.Free;
      end;

    finally
      Objs.Free;
    end;

    AEditor.MultiSize := True;
    AEditor.RulerVisible := False;

    if ADocMode = dmView then
    begin
      AEditor.ActivateLinks(Links);
      Links := nil;
      AEditor.EditMode := emReadOnly;
      AEditor.TextFile.StrictReadOnly := True;
    end;

    AEditor.TextFile.GotoSOF;
    AEditor.MakeUndoRoot;

  finally
    Links.Free;
  end;

end;

class function TASDoc.Parse(const ASource: TArray<string>;
  ALinks: THyperlinks; AObjects: TObjectDictionary<Integer, TAlgosimObject>): TTextFile;

var
  TextFile: TTextFile absolute Result;

  function TextFmt(const s: string): string;
  var
    i, c: Integer;
    InLink: Boolean;
    InLinkURL: Boolean;
    Anchor: Integer;
    LinkPos: TPoint;
    LinkCaption: string;
    LinkURL: string;

    procedure LinkEnd;
    begin
      if Assigned(ALinks) then
      begin
        ALinks.Add(
          THyperlink.Create(
            LinkPos,
            c - 1,
            LinkCaption,
            LinkURL
          )
        )
      end;
      InLink := False;
      InLinkURL := False;
      LinkPos := TPoint.Zero;
      LinkCaption := '';
      LinkURL := '';
    end;

  begin
    SetLength(Result, s.Length);
    c := 0;
    i := 1;
    InLink := False;
    InLinkURL := False;
    Anchor := 0;
    while i <= s.Length do
    begin
      if InLinkURL then
      begin
        if s[i] = '›' then
        begin
          LinkURL := Copy(s, Anchor, i - Anchor);
          LinkEnd;
        end;
      end
      else if InLink then
      begin
        if (s[i] = '›') or ((s[i] = '|') and (i > Anchor)) then
        begin
          LinkCaption := Copy(s, Anchor, i - Anchor);
          if s[i] = '|' then
          begin
            Anchor := Succ(i);
            InLinkURL := True;
          end
          else
            LinkEnd;
        end
        else
        begin
          Inc(c);
          Result[c] := s[i];
        end;
      end
      else if s[i] = '‹' then
      begin
        InLink := True;
        Anchor := Succ(i);
        LinkPos := Point(c, TextFile.LogicalLineCount);
        LinkCaption := '';
        LinkURL := '';
      end
      else if s[i] = '`' then
      begin
        if (i < s.Length) and (s[Succ(i)] = '`') then
        begin
          Inc(c);
          Result[c] := s[i];
          Inc(i);
        end;
      end
      else
      begin
        Inc(c);
        Result[c] := s[i];
      end;
      Inc(i);
    end;
    SetLength(Result, c);
  end;

var
  ObjID: Integer;
  LKernel: TASKernel;

  procedure AddButton(const AScript: string);
  begin
    Result.AddLine('<!<' + AScript.Replace(#13#10, #32));
  end;

  procedure RunScript(const AScript: string; ATextLevelParsing: Boolean = False);
  var
    Obj: TAlgosimObject;
    S, L: string;
  begin
    if LKernel = nil then
    begin
      LKernel := TASKernel.Create;
      LKernel.PropStore.AddSubstore(TFrontEndProperties.Create(AlgosimMainForm));
    end;
    try
      Obj := LKernel.Evaluate(AScript);
      if Assigned(AObjects) and ((Obj is TAlgosimPixmap) or (Obj is TAlgosimSound)) then
      begin
        AObjects.Add(ObjID, Obj.Clone);
        Result.AddLine('<~<' + ObjId.ToString);
        Inc(ObjID);
      end
      else
      begin
        S := Obj.ExplainedOutput(DefaultFormatOptions);
        for L in S.Split([sLineBreak]) do
          if ATextLevelParsing then
            Result.AddLine(TextFmt(L))
          else
            Result.AddLine(L, 'Output');
      end;
    except
      Result.AddLine('Couldn''t evaluate expression.', 'Error output');
    end;
  end;

var
  i, j: Integer;
  s: string;
  InInput, InOutput, Execute, Button, Generate: Boolean;
  Script: string;
begin

  LKernel := nil;
  ObjID := 0;
  try

    Result := TTextFile.Create;
    try
      Result.UseLineClasses := True;
      Result.ControlAware := True;
      Result.BeginAddLine;
      try
        InInput := False;
        InOutput := False;
        Execute := False;
        Button := False;
        Generate := False;
        for i := 0 to High(ASource) do
        begin
          s := ASource[i];
          if s.StartsWith('~html') or s.StartsWith('~set') or s.StartsWith('~clear') or s.StartsWith('~img') then
            Continue;
          if InInput then
          begin
            if S.Trim.StartsWith('>>') then
            begin
              InInput := False;
              if Button then
                AddButton(Script)
              else if Execute or Generate then
                RunScript(Script, Generate);
              Execute := False;
              Button := False;
              Generate := False;
              Continue;
            end;
            if not Generate then
              Result.AddLine(S, 'Input');
            if Button or Execute or Generate then
              Script := Script + S + sLineBreak;
          end
          else if InOutput then
          begin
            if S.Trim = '<<' then
            begin
              InOutput := False;
              Continue;
            end;
            Result.AddLine(S, 'Output');
          end
          else if s.Trim.IsEmpty then
            Result.AddLine('')
          else
            case S[1] of
              '§':
                begin
                  j := 2;
                  while (j <= s.Length) and (s[j] = '§') do
                    Inc(j);
                  Dec(j);
                  Result.AddLine(TextFmt(Copy(s, Succ(j)).Trim),
                    Format('Heading %d', [j]));
                end;
              '>':
                begin
                  if S.Trim = '>>@' then
                  begin
                    InInput := True;
                    Generate := True;
                    Script := '';
                    Continue;
                  end;
                  if S.Trim = '>>!' then
                  begin
                    InInput := True;
                    Button := True;
                    Script := '';
                    Continue;
                  end;
                  if S.Trim = '>>>' then
                  begin
                    InInput := True;
                    Execute := True;
                    Script := '';
                    Continue;
                  end;
                  if S.Trim = '>>' then
                  begin
                    InInput := True;
                    Continue;
                  end;
                  Result.AddLine(Copy(s, 2).Trim, 'Input');
                end;
              '<':
                begin
                  if S.Trim = '<<' then
                  begin
                    InOutput := True;
                    Continue;
                  end;
                  Result.AddLine(Copy(s, 2).Trim, 'Output');
                end;
              '!':
                begin
                  Result.AddLine(Copy(s, 2).Trim, 'Error output');
                end;
            else
              Result.AddLine(TextFmt(s.TrimRight));
            end;
        end;
        Result.AddLine('');
      finally
        Result.EndAddLine;
      end;

    except
      Result.Free;
      raise;
    end;

  finally
    LKernel.Free;
  end;

end;

class procedure TASDoc.PrepareViewer(AEditor: TTextEditor);
begin
  if AEditor = nil then
    Exit;
  AEditor.LoadDefaultClasses;
  AEditor.AddClass(MakeClass('Input', AEditor.Font.Size, [fsBold], AEditor.Font.Color));
  AEditor.AddClass(MakeClass('Output', AEditor.Font.Size, [], AEditor.Font.Color));
  AEditor.AddClass(MakeClass('Error output', AEditor.Font.Size, [], clRed));
end;

class procedure TASDoc.RequireDoc;
begin
  if not TDirectory.Exists(Path) then
    raise EDocException.Create('Documentation not installed.');
end;

class procedure TASDoc.SaveToFile(const ATopicName: string; AEditor: TTextEditor;
  const AFileName: TFileName);
var
  Zip: TZipFile;
  i: Integer;
  CtlId: Integer;
  Ctl: TControl;
  Png: TPngImage;
  ImgStream: TMemoryStream;
begin

  if AEditor = nil then
    Exit;

  AEditor.TidyControlIDs;

  Zip := TZipFile.Create;
  try
    Zip.Open(AFileName, zmWrite);
    try
      Zip.Add(TEncoding.UTF8.GetBytes(AEditor.PlainText), 'text.asml');
      Zip.Add(TEncoding.UTF8.GetBytes(ATopicName), 'name.txt');
      Zip.Add(TEncoding.UTF8.GetBytes(GetMetadata), 'metadata.txt');
      for i := 0 to AEditor.LineCount - 1 do
      begin
        CtlId := AEditor.GetControlIDFromLine(i);
        if CtlId = -1 then
          Continue;
        Ctl := AEditor.GetControlFromID(CtlId);
        if Ctl is TImage then
        begin
          Png := TPngImage.Create;
          try
            Png.Assign(TImage(Ctl).Picture.Graphic);
            ImgStream := TMemoryStream.Create;
            try
              Png.SaveToStream(ImgStream);
              ImgStream.Position := 0;
              Zip.Add(ImgStream, Format('image%d.png', [CtlId]));
            finally
              ImgStream.Free;
            end;
          finally
            Png.Free;
          end;
        end;
      end;
    finally
      Zip.Close;
    end;
  finally
    Zip.Free;
  end;

end;

class function TASDoc.Search(const ASearchText: string;
  ASearchOptions: TStringSearchOptions): TArray<string>;
var
  fn: string;
  Zip: TZipFile;
  TextBytes: TArray<Byte>;
  Text: string;
  Lines: TArray<string>;
  i, p: Integer;
  Matches: TList<string>;
  LSearchText: string;
begin

  Result := nil;

  if FFullTextIndex = nil then
  begin

    fn := TPath.Combine(FPath, 'fulltext.ftx');

    if not TFile.Exists(fn) then
      Exit;

    Zip := TZipFile.Create;
    try
      Zip.Open(fn, zmRead);
      try
        if Zip.IndexOf('fulltext.txt') = -1 then
          Exit;
        Zip.Read('fulltext.txt', TextBytes);
        Text := TEncoding.UTF8.GetString(TextBytes);
        Lines := Text.Split([#13#10]);
        SetLength(FFullTextIndex, Length(Lines));
        for i := 0 to High(Lines) do
        begin
          p := Pos(#9, Lines[i]);
          if p = 0 then
            Continue;
          FFullTextIndex[i].Key := Copy(Lines[i], 1, p - 1);
          FFullTextIndex[i].Value := Copy(Lines[i], p + 1);
        end;
      finally
        Zip.Close;
      end;
    finally
      Zip.Free;
    end;

  end;

  LSearchText := ProcessText(ASearchText);

  Matches := TList<string>.Create;
  try
    for i := 0 to High(FFullTextIndex) do
      if ASStrFcns.SubstringFirstIndex(LSearchText, FFullTextIndex[i].Value, ASearchOptions) <> 0 then
        Matches.Add(FFullTextIndex[i].Key);
    Result := Matches.ToArray;
  finally
    Matches.Free;
  end;

end;

{ TString }

procedure TString.Append(C: Char);
begin
  if FActualLength = FData.Length then
    SetLength(FData, FData.Length + AllocStep);
  Inc(FActualLength);
  FData[FActualLength] := C;
end;

procedure TString.Append(const S: string);
begin
  if S.IsEmpty then Exit;
  if FActualLength + S.Length > FData.Length then
    SetLength(FData, FActualLength + S.Length + AllocStep);
  MoveChars(S[1], FData[FActualLength + 1], S.Length);
  Inc(FActualLength, S.Length);
end;

constructor TString.Create(ACapacity: Integer);
begin
  SetLength(FData, ACapacity);
  FActualLength := 0;
end;

class operator TString.Explicit(const S: TString): string;
begin
  SetLength(Result, S.FActualLength);
  if Result.Length > 0 then
    MoveChars(S.FData[1], Result[1], Result.Length);
end;

class operator TString.Implicit(const S: string): TString;
begin
  Result.FData := S;
  Result.FActualLength := S.Length;
end;

procedure TString.TrimExcess;
begin
  SetLength(FData, FActualLength);
end;

end.