AGB  ·  Datenschutz  ·  Impressum  







Anmelden
Nützliche Links
Registrieren
Zurück Delphi-PRAXiS Tutorials Anzeigen eines Ebooks im epub-Format

Anzeigen eines Ebooks im epub-Format

Ein Tutorial von Ralf Stehle · begonnen am 4. Feb 2014 · letzter Beitrag vom 2. Aug 2015
Antwort Antwort
Ralf Stehle
Registriert seit: 8. Aug 2003
Kurze Übersicht über das Thema Epub-Format

Bevor es weiter unten mit dem Delphi-Projekt losgeht, möchte ich eine kurze Übersicht über das Epub-Format geben.
Das Delphi-Projekt wurde mit der Delphi-Version 7 unter Windows XP und Window 8 erstellt:


In jedem Archiv sind die Unterverzeichnisse META-INF und OPS enthalten

META-INF enthält die Datei container.xml
OPS enthält alle Buchseiten im html oder xhtml-Format


Die Datei container.xml enthält eine html-Referenz auf die Datei content.opf
In content.opf sind sämtliche Teile des Ebook im xml-Format aufgelistet. Daher ist diese Datei die zentrale Information über das Ebook


Meistens, aber nicht immer liegt die Datei content.opf im Verzeichnis OPS
Daneben gibt es für die Navigation auch eine Datei toc.ncx. Die Reihenfolge der Seiten ist aber auch bereits in content.opf enthalten

Alle Inhalte liegen im Unicode-Dateiformat vor.


Weitere Informationen über das Epub-Format unter:

http://de.wikipedia.org/wiki/EPUB
http://www.iks.hs-merseburg.de/~mein...S2_Meinike.pdf
https://hpv.leo.uberspace.de/wordpress/epub-cover/
http://www.jedisaber.com/eBooks/Tutorial.shtml
http://www.hxa.name/articles/content...7241_2007.html
Ralf Stehle
ralfstehle@yahoo.de
 
Ralf Stehle

 
Delphi 7 Professional
 
#2
  Alt 4. Feb 2014, 19:44
Delphi-Projekt Epub

Um ein Ebook im epub-Format anzuzeigen, muss das File also zuerst entpackt werden. Ich benutze hierfür die kostenlose Komponente kazip20. Diese Komponente erlaubt es sogar, einzelne Dateien in einem Stream zu öffnen, ohne das gesamte Archiv zu entpacken

Jede andere Zip-Komponente funktioniert aber sicher ähnlich

Download einiger freien Zip-Komponenten:
http://www.delphipages.com/forum/showthread.php?t=27536

Für das Epub habe ich eine eigene Klasse TEpub angelegt:
http://www.delphipraxis.net/92358-ei...klarieren.html


Delphi-Quellcode:
unit uEpub;

interface

uses
  //jpeg und XmlDoc wird benötigt, für die Unzip-Funktion hier auch KaZip
  Windows, Messages, ...., jpeg, KaZip, XmlDoc;

 //Strukturierter Datentyp erleichtert es die Übersicht zu behalten
 type contentRec = record
  title : String;
  creator : String;
  publisher : String;
  subject : String;
  description : String;
  metadata : String;
  isbn : String;
  uuid : String;
  asin : String;
  mname : String; //für <meta name= cover
  mcover : String; //für <meta name= cover
  mbookid : String;
  opfFile : String;
  opfDir : String;
  opfContent : String;
  coverpath : String;
  ms : TMemoryStream;
  html : Array of Array of string;
end;


type
  TEpub = class
    procedure ParseEpub(epubfile: string; var cr: contentRec);

  private
    { Private declarations }
    function ExtractOpfDir(s: string): string;
    function ExtractHtmlTags(s:string):string;

  public
    { Public declarations }
  end;

var
  Epub: TEpub;

implementation


//Beispiel: Unit1 ist Unit meiner Applikation
uses Unit1;

//epubfile ist der Pfad zu meiner *.epub z.B. 'Strobel, Arno - Das Rachespiel.epub'
procedure TEpub.ParseEpub(epubfile: string; var cr: contentRec);
var
  posStart, posdc, posEnd, pos1, pos2, posfullpath, posidref, poshref, posid, postitle, postype, posmedia: integer;
  s, sub1, xdir: string;
  shref, stitle, sid, smedia, sidref, stype: string;
  KaZip1: TKaZip;
  index, z, h: integer;
  arrTemp: Array of Array of string;
  XmlDoc: TXMLDocument;
  this: boolean;
begin
  //Zip-Komponente zur Laufzeit implementieren
  KaZip1 := TKaZip.Create(nil);

  {****************************************************************************}
  {*                             Container.xml                                *}
  {****************************************************************************}

  // Container.xml enthält den Pfad zur wichtigen Datei content.opf
  try
   KaZip1.Open(epubfile);
   index := KAZip1.Entries.IndexOf('META-INF\Container.xml');
   if index < 0 then exit;
   KAZip1.ExtractToStream(KAZip1.Entries.Items[Index], cr.ms);
   //Alternative: entpacken statt einlesen: KAZip1.ExtractToFile(KAZip1.Entries.Items[index], 'D:\Ziel\Container.xml');
   KaZip1.Active := true;
   cr.ms.seek(0,sofromBeginning);

   //Die Datei Container.xml ist jetzt im MemoryStream cr.ms und kann per Xml-Parser dursucht werden
   XmlDoc := TXMLDocument.Create(Application);
   try
    XmlDoc.Active := True;
    XmlDoc.LoadFromStream(cr.ms);
    //Suche: <rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
    cr.opfFile := XmlDoc.DocumentElement.ChildNodes['rootfiles'].ChildNodes['rootfile'].AttributeNodes['full-path'].Text;
    cr.opfDir := ExtractOpfDir(cr.opfFile); //wird später noch als relativer Pfad benötigt!
    XMLDoc.Active := False;
   finally
    XmlDoc := nil;
   end;

   cr.ms.Clear;
   finally
  end;

  {****************************************************************************}
  {*                             content.opf                                  *} 
  {****************************************************************************}

  //Ab hier geht es nur noch um Inhalte der Datei content.opf
  //Achtung: Pfadangaben sind immer relativ zum Pfad der content.opf !!

  try
   index := KAZip1.Entries.IndexOf(cr.opfFile);
   //auch hier habe ich die Datei content.opf in einen Stream cr.ms eingelesen.
   //Alternativ ist das epub-Archiv vielleicht aber auch schon entpackt
   KAZip1.ExtractToStream(KAZip1.Entries.Items[Index], cr.ms);
   if index < 0 then exit;
   KaZip1.Active := true;
   cr.ms.seek(0,sofromBeginning);
   //so kann man einen Stream in eine string-Variable übergeben. s bzw cr.opfContent wird aber nur zum testen benötigt
   SetString(s, PAnsiChar(cr.ms.Memory), cr.ms.Size); {set string to get memory}
   cr.opfContent := s;

   //hier wird der Stream in den Xml-Parser übergeben
   XmlDoc := TXMLDocument.Create(Application);
   XmlDoc.Active := True;
   XmlDoc.LoadFromStream(cr.ms);

   cr.ms.Clear;
  finally
  end;


  {****************************************************************************}
  {*                          <Metadata> durchsuchen                          *}
  {****************************************************************************}
  //Test: Attribut auslesen: ShowMessage(XmlDoc.DocumentElement.ChildNodes['metadata'].ChildNodes['meta'].Attributes['content']);
  //Test: funktioniert nicht wegen dem Doppelpunkt?: ShowMessage(XmlDoc.DocumentElement.ChildNodes['metadata'].ChildNodes['dc:title'].Text);
  //Test: funktioniert mit Integer (als Iterations-Schleife benutzbar): ShowMessage(XmlDoc.DocumentElement.ChildNodes['metadata'].ChildNodes[8].Text);
  
  With XmlDoc.DocumentElement.ChildNodes['metadata'] do
  begin
    for h := 0 to ChildNodes.Count - 1 do
    begin
      if ChildNodes[h].NodeName = 'dc:title'       then cr.title := ChildNodes[h].Text;
      if ChildNodes[h].NodeName = 'dc:creator'     then cr.creator := ChildNodes[h].Text;
      if ChildNodes[h].NodeName = 'dc:publisher'   then cr.publisher := ChildNodes[h].Text;
      if ChildNodes[h].NodeName = 'dc:descriptionthen cr.description := ExtractHtmlTags(ChildNodes[h].Text);
      if ChildNodes[h].NodeName = 'dc:subject'     then cr.subject := ChildNodes[h].Text;
      
      //Weitere Möglichkeiten dc:contributor dc:source dc:relation dc:coverage dc:language dc:rights

      if ChildNodes[h].NodeName = 'metathen
       if (ChildNodes[h].Attributes['name']<>null)
        and (ChildNodes[h].Attributes['name'] = 'cover')
         then cr.mcover := ChildNodes[h].Attributes['content'];
         //Weitere Möglichkeiten für <meta name
          //außer cover auch:
            //igil version, generator, calibre:series_index, calibre:user_metadata:#gelesen,
            //calibre:timestamp, calibre:user_categories, calibre:title_sort, calibre:rating,
            //calibre:author_link_map, calibre:user_metadata:#formats

      if ChildNodes[h].NodeName = 'dc:identifierthen
       if (ChildNodes[h].Attributes['opf:scheme']<>null)
        and (ChildNodes[h].Attributes['opf:scheme'] = 'uuid')
         then cr.uuid := ChildNodes[h].Text;
      
      if ChildNodes[h].NodeName = 'dc:identifierthen
       if (ChildNodes[h].Attributes['opf:scheme']<>null)
        and (ChildNodes[h].Attributes['opf:scheme'] = 'isbn')
         then cr.isbn := ChildNodes[h].Text;

      if ChildNodes[h].NodeName = 'dc:identifierthen
       if (ChildNodes[h].Attributes['opf:scheme']<>null)
        and (ChildNodes[h].Attributes['opf:scheme'] = 'asin')
         then cr.asin := ChildNodes[h].Text;

      if ChildNodes[h].NodeName = 'dc:identifierthen
       if (ChildNodes[h].Attributes['opf:scheme']<>null)
        and (LowerCase(ChildNodes[h].Attributes['opf:scheme']) = 'mobi-asin')
         then cr.asin := ChildNodes[h].Text;

      if ChildNodes[h].NodeName = 'dc:identifierthen
       if (ChildNodes[h].Attributes['id']<>null)
        and (LowerCase(ChildNodes[h].Attributes['id']) = 'bookid')
         then cr.mbookid := ChildNodes[h].Text;

    end;
  end;



  {****************************************************************************}
  {*                          <Manifest> durchsuchen                          *}
  {****************************************************************************}
  for h := 0 to XmlDoc.DocumentElement.ChildNodes['manifest'].ChildNodes.Count - 1 do
  begin
    shref := XmlDoc.DocumentElement.ChildNodes['manifest'].ChildNodes[h].AttributeNodes['href'].Text;
    sid := XmlDoc.DocumentElement.ChildNodes['manifest'].ChildNodes[h].AttributeNodes['id'].Text;
    smedia := XmlDoc.DocumentElement.ChildNodes['manifest'].ChildNodes[h].AttributeNodes['media-type'].Text;

    
    //*** Cover steht in manifest entweder unter id=cover
    //*** oder in Metadata <meta name=cover content=xyz.jpg
      //Nicht berücksichtigt: das Cover kann auch in einer extra html oder xhtlm-Datei referenziert werden

    if (pos('cover', sid) > 0) and (smedia = 'image/jpeg') then cr.coverpath := shref;
    if cr.coverpath = 'then if (pos(cr.mcover, sid) > 0) and (smedia = 'image/jpeg') then cr.coverpath := shref;

    //temporäres dreidimensionales Array mit allen "html" bzw. "htm" bzw. "xhtml"-Dateiangaben füllen.
    //temporär deshalb, weil leider die Reihenfolge der Buchseiten später in spine noch sortiert werden muss.
    //Das sortierte Array ist cr.html und wird später gefüllt:
    if pos('htm', smedia)>0 then
    begin
      SetLength(arrTemp, h+1, 3);
      arrTemp[h, 0] := sid; arrTemp[h, 1] := shref; arrTemp[h, 2] := smedia; //ShowMessage(shref + ' ' + sid + ' ' + smedia);
    end;
  end;



  {****************************************************************************}
  {*                            <Spine> durchsuchen                           *}
  {****************************************************************************}

  //Spine enthält die Reihenfolge der Buchseiten
  //<spine toc="ncx"><itemref idref="cover"/><itemref idref="haupttitel"/><itemref idref="chapter1"/></spine>

  for h := 0 to XmlDoc.DocumentElement.ChildNodes['spine'].ChildNodes.Count - 1 do
  begin
    sidref := XmlDoc.DocumentElement.ChildNodes['spine'].ChildNodes[h].AttributeNodes['idref'].Text;

    //Inhaltsverzeichnis sidref eintragen.
    //Die Array-Länge wird mit SetLength angepasst und ergibt sich durch die Variable h in der Iteration (Schleife for h=0 to ...)
    SetLength(cr.html, h+1, 3);
    cr.html[h,0] := sidref;
  end;
  //Test: ShowMessage(IntToStr(high(cr.html)) + ' Einträge in spine gefunden');

  //die erste Dimesion des Arrays cr.html[x,0] ist bereits in der korrekten Reihenfolge gefüllt,
  //die anderen Werte werden aus dem temporären Array arrTemp kopiert
  for h := low(cr.html) to high(cr.html) do
  begin
    //Test: ShowMessage(cr.html[h,0]);
    for z := low(arrTemp) to high(arrTemp) do
      if cr.html[h,0] = arrTemp[z,0] then
      begin
        cr.html[h,1] := arrTemp[z,1]; cr.html[h,2] := arrTemp[z,2];
      end;
  end;
  arrTemp := nil;
  //Test: for h := low(cr.html) to high(cr.html) do ShowMessage('Sortiert: ' + #13 + cr.html[h,0] + #13 + cr.html[h,1] + #13 + cr.html[h,2]);



  {****************************************************************************}
  {*                     <Guide><reference> durchsuchen                       *}
  {****************************************************************************}

  // so könnte auch der Abschnitt Guide der Datei content.opf durchsucht werden. Wird aktuell aber nicht benötigt

  for z := 0 to XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes.Count - 1 do
  begin
    shref := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['href'].Text;
    stitle := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['title'].Text;
    stype := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['type'].Text;
    //ShowMessage(shref + ' ' + stitle + ' ' + stype);
  end;


  //XmlDoc mit dem Inhalt der Datei content.opf freigeben
  XMLDoc.Active := False;
  XmlDoc := nil;

  


  {****************************************************************************}
  {*                           Titelbild suchen                               *}
  {****************************************************************************}

  // hier habe ich versucht, das Titelbild zu suchen und in einen Stream einzulesen.
  // Das ist leider unübersichtlich, da viele verschiedene Pfad-Varianten berücksichtigt werden müssen.
  // Leider fehlt die 3. Variante mit eigener html-Datei als Bildreferenz

  // 1. Cover als Bilddatei in Metadaten: <meta name="cover" content="xyzcover.jpg"/>
  // 2. Cover als Bilddatei in Manifest: <item href="cover.jpeg" id="cover" media-type="image/jpeg"/>
  // oder so <item href="images/cover.jpg" id="cover-image" media-type="image/jpeg"/> }
  // 3. Cover in eigener html-Datei nicht fertig: <body><img src="buch.jpg" alt="Mein Buchtitel"/>

  if cr.coverpath <> 'then
  begin
    try
      //coverpath kann in Unterverzeichnis opfDir liegen. opfDir kann leer bleiben, das stört nicht
      // aber: ../cover.jpg bedeutet, dass opfDir ignoriert werden muss.
      // ../ muss dann aber auch noch abgeschnitten werden
      if cr.opfDir <> ''                 then xdir := cr.opfDir + cr.coverpath; //1.
      if pos('../', cr.coverpath) > 0 then xdir := RightStr(cr.coverpath, Length(cr.coverpath)-3); //2. Ausnahme von 1.
      if cr.opfDir = ''                  then xdir := cr.coverpath; //3. noch unvollständig

      index := KAZip1.Entries.IndexOf(xdir);
      if (index > 0) then
      begin
        KAZip1.ExtractToStream(KAZip1.Entries.Items[index], cr.ms);
        cr.ms.seek(0,sofromBeginning);
      end;
    finally
    end;
  end;

  {****************************************************************************}
  //Zip-Komponente freigeben
  KaZip1.Free;
end;

function TEpub.ExtractOpfDir(s: string): string;
var
  pos1: integer;
begin
  pos1 := 0;

  //letzten Slash suchen:
  repeat pos1 := posex('/', s, pos1+1) until posex('/', s, pos1+1) = 0;

  if pos1 = 0 then result := ''; //kein Slash = kein Verzeichnis
  if pos1 > 0 then result := Copy(s,1, pos1); //Verzeichnis mit abschließendem Slash
end;

// für cr.description benutzt, um die Inhaltsangabe der Bücher in <dc:description> als reinen string zu erhalten
function TEpub.ExtractHtmlTags(s: string):string;
begin
  s := (StringReplace(s, '&quot;' , '"', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '&amp;'  , '&', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<'   , '<', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '>'   , '>', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<br>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<p>'    , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</p>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<div>'  , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</div>' , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '–'    , '-', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<i'      , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '/i'     , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<h1>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</h1>'  , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<h2>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</h2>'  , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<h3>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</h3>'  , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<em>'   , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '</em>'  , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '‹'    , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '›'    , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '›'    , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '{'      , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '}'      , '', [rfReplaceAll, rfIgnoreCase]));
  s := (StringReplace(s, '<p class="description">' , '', [rfReplaceAll, rfIgnoreCase]));

  Result := s;
end;

end.


Die Klasse kann einfach zu einem Delphi-Projekt hinzugefügt werden

Hier eine Beispiel-Anwendung:

In meiner Anwendung verwalte ich eine Liste aller meiner Epub-Bücher.
Als Liste benutzte ich ein AdoDataSet. Das kann als Verbindung zu einer Access-Datei dienen,
aber auch ungebunden benutzt werden

Beim Scrollen innerhalb der Bücher soll das Titelbild und die Metadaten angezeigt werden


Delphi-Quellcode:
procedure TForm1.ADODataSet1AfterScroll(DataSet: TDataSet);
var
  cr: contentRec; //Datentyp aus Epub.pas
  jpeg: TJPEGImage; //uses Jpeg
begin
  //Cover in MemoryStream cr.ms laden {Datentyp aus Epub.pas}
  Image1.Picture.Assign(nil); //altes Bild entfernen

  filename := AdoDataSet1.FieldByName('Dateiname').AsString;
  if ExtractFileExt(filename) <> '.epubthen exit;

  jpeg := TJPEGImage.Create;

  //Wichtig: vor Aufruf von Epub.ParseEpub(filename, cr) immer initialisieren
  cr.ms := TMemoryStream.Create;
  try
    Epub.ParseEpub(filename, cr);

    //ein Bild wurde gefunden
    if (cr.coverpath <> '') and (cr.ms.Size>0) then
    begin
      if (Image1.Picture.Graphic = nil) or (Image1.Picture.Graphic.Empty) then
      begin
        jpeg.LoadFromStream(cr.ms); // falls epub entpackt wurde auch LoadFromFile() möglich
        Image1.Picture.Assign(jpeg);
      end;
    end;

    //Metadaten in einem TMemo anzeigen
    With Memo1.Lines do
    begin
      Clear;
      Add(cr.title);
      Add(cr.subject);
      Add(cr.description);
      Add(cr.creator);
      Memo1.Perform(WM_VSCROLL, SB_TOP, 0); //nach unten scrollen
     end;
  finally
    jpeg.free;
  end;
end;
  Mit Zitat antworten Zitat
Ralf Stehle

 
Delphi 7 Professional
 
#3
  Alt 5. Feb 2014, 23:47
Epub-Viewer mit TWebbrowser erstellen


Da ein Epub eine Archivdatei im Zipformat mit html oder xhtml-Seiten ist,
kann der TWebbrowser für die Darstellung von Epubs benutzt werden.

Das Epub wird dabei vollständig mit allen Seiten und nicht nur seitenweise in den Webbrowser geladen.

Es gibt eine Seitenanzeige und man kann durch Seiten und Kapitel navigieren.
Die Schriftgröße kann verändert werden

Probleme mit dem TWebbrowser in Delphi7 mit dem Unicode-Format wurden berücksichtigt
Das Fehlen eines Scroll-Events wurde einfach durch einen Timer gelöst, der ständig die
Seitenzahlen neu berechnet

Im Gegensatz zum 2. Teil habe ich statt Kazip20 jetzt die Abbrevia-Zip-Komponente benutzt.
Leider musste ich feststellen, das KaZip nicht alle Epubs entpacken kann



Delphi-Quellcode:
unit uViewer;

interface

uses
  Windows .......,
  ActiveX, mshtml, ShellApi, SHDocVw, AbUnzper, AbArcTyp;


type
  TViewer = class(TForm)
    PanelTop: TPanel;
    ShowEpub: TTimer;
    ......

  private
    { Private-Deklarationen }
    function IsInteger(str: string): Boolean;
    function deldir(dir: string): Boolean;
    function WB_GetScrollPosition(WB: TWebBrowser; var ScrollPos: TPoint): Boolean;

  public
    { Public-Deklarationen }
  end;

var
  Viewer: TViewer;


implementation

uses uEpub; // die Unit uEpub aus Teil 2 wird benötigt

{$R *.dfm}

var
  //Variablen für die Steuerung im Buch
  PageNr, KapitelNr: integer;
  MaxPage, ThisPage, MaxLines, VisibleLines, FirstVisibleLine, LastVisibleLine: integer;

  //Variable für das Vergrößern und Verkleinern der Schrift
  wbZoom: OleVariant;

  tempPath: string;

  //Strukturierter Datentyp aus uEpub
  cr: contentRec;

procedure TViewer.FormCreate(Sender: TObject);
begin
  wbZoom := 100;
  PageNr := 1;
  KapitelNr := 1;
  tempPath := SysUtils.GetEnvironmentVariable('temp') + '\unzip\'; //hierhin wird das Epub entzippt
end;



//Erst wird die Applicatin mit dem Webbrowser angezeigt.
//Der Timer verzögert das Laben des Epub, sonst wird die Application erst danach sichtbar
procedure TViewer.ShowEpubTimer(Sender: TObject);
var
  UnZip: TAbUnZipper; // diesesmal benutze ich die Abbrevia-Komponente
  s: string;
  i, j, k, index, posEnd: integer;

  WebDoc: HTMLDocument;
  WebBody: HTMLBody;
  Range: IHTMLTxtRange;
begin
  ShowEpub.Interval := 0; // sonst macht der Timer eine Endlosschleife

  //Das Epub wird in das temporäre Verzeichnis entpackt
  UnZip:= TAbUnZipper.create(nil); //uses AbUnzper
  UnZip.FileName:= 'MeinNeuesEbook.epub';
  UnZip.BaseDirectory:= ExtractFilePath(tempPath);
  UnZip.ExtractOptions := [eoCreateDirs, eoRestorePath]; //uses AbArcTyp
  UnZip.ExtractFiles('*.*');

  UnZip.OpenArchive(Form1.filename); //für ExtractToStream weiter unten

  cr.ms := TMemoryStream.Create; //vor Aufruf von Epub.ParseEpub(filename, cr) initialisieren
  Epub.ParseEpub('MeinNeuesEbook.epub', cr);

  //Erste Seite per Navigate anzeigen. Im cr.html-Array aus uEpub stehen alle html-Seiten des Epub zur Verfügung
   WB.Navigate(tempPath + cr.opfDir + cr.html[1, 1]);
   Application.ProcessMessages;

   //statt WB.Navigate werden die restlichen Seiten so angefügt.
   //so hat man das komplette Ebook im Webbrowser als eine fortlaufende Seite geladen
   for i := low(cr.html) to high(cr.html) do
   begin
     cr.ms.Clear;
     UnZip.ExtractToStream(cr.opfDir + cr.html[i,1], cr.ms);
     cr.ms.seek(0,sofromBeginning);
     SetString(s, PAnsiChar(cr.ms.Memory), cr.ms.Size); {set string to get memory}

     //Utf8Decode führt häufig zu einem leeren String, wenn am Schluss sogenannte "invalide byte sequence" vorhanden sind
     //daher Ende der html-Datei bestimmen und string dahinter abschneiden
     posEnd := pos('</html>', s) + Length('</html>');
     s := Utf8Decode(Copy(s, 1, posEnd)) + '<p> &nbsp;</p>';

     //Warten bis eine Seite fertig geladen wurde...
     while (WB.ReadyState < READYSTATE_INTERACTIVE) do Application.ProcessMessages;

     if Assigned(WB.Document) then
     begin
       //http://www.swissdelphicenter.ch/torry/showcode.php?id=2148
       Range := ((WB.Document as IHTMLDocument2).body as IHTMLBodyElement).createTextRange;
       Range.collapse(False);
       Range.pasteHTML(s);
       //hier füge ich bei jeder neuen html-Seite ein unsichtbaren Text ekapitel1 bis x ein
       //damit kann ich später durch die Kapitel navigieren
       Range.pasteHTML('<span style="color:white">ekapitel' + IntToStr(i) + '</span><span style="color:black">&nbsp;</span>');

     end;

     Application.ProcessMessages;
   end;

  UnZip.Free;
  BerechneSeiten.Interval := 500; //Seitenzähler aktivieren, ohne Verzögerung Errormeldung
end;


procedure TViewer.WBDocumentComplete(Sender: TObject; const pDisp: IDispatch; var URL: OleVariant);
var
  RefreshLevel: OleVariant;
begin
  //in Delphi7 gibt es ein Problem mit Unicode. Diese Zeilen lösen das Problem leider nur
  //bei Seiten die mit Webbrowser.Navigate geladen wurden.
  if Assigned(WB) then
  try
    IHTMLDocument2(WB.Document).Set_CharSet('utf-8'); //'utf-8' iso-8859-1 oder 'iso-8859-2'
    RefreshLevel := 7;
    WB.Refresh2(RefreshLevel);
  except
  end;
end;



//So kann der Text verkleinert werden
procedure TViewer.BitBtnFontDownsizeClick(Sender: TObject);
begin
  wbZoom:= wbZoom - 10;
  WB.ExecWB(63, OLECMDEXECOPT_PROMPTUSER, wbZoom);
end;

//und so vergößert werden
procedure TViewer.BitBtnFontUpsizeClick(Sender: TObject);
begin
  wbZoom:= wbZoom + 10;
  WB.ExecWB(63, OLECMDEXECOPT_PROMPTUSER, wbZoom);
end;

//Ab hier geht es nur noch um das Navigieren im Ebook
procedure TViewer.BitBtnForwardsClick(Sender: TObject);
var
  i: integer;
  anchor: OleVariant;
begin
  //Im Text scrollen, falls die Seite nicht verlassen wurde
  if pos(cr.opfDir + cr.html[PageNr, 1], WB.LocationURL)>0 then
    WB.OleObject.Document.ParentWindow.ScrollBy(0, WB.Height) //funktioniert nicht immer: OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop := OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop + WB.Height;
  else
    WB.GoForward; //falls Seite über Link verlassen wurde, muss normale Browser-Navigation aktiviert werden
end;

procedure TViewer.BitBtnBackwardsClick(Sender: TObject);
begin
  //ist die Url noch diesselbe wie das Epub? Dann Scrollen, sonst Navigieren mit WB.GoBack
  if pos(cr.opfDir + cr.html[PageNr, 1], WB.LocationURL) > 0 then
    WB.OleObject.Document.ParentWindow.ScrollBy(0, -WB.Height) //Variante funktioniert nicht immer: OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop := OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop + WB.Height;
  else
    WB.GoBack; //falls Seite über Link verlassen wurde, muss normale Browser-Navigation aktiviert werden
end;

//hier kann in einem TEdit mit Namen "edThisPage" direkt zu einer Seite gesprungen werden
procedure TViewer.edThisPageKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
begin
  BerechneSeiten.Interval := 0;
  if Key <> VK_RETURN then exit;
  if not IsInteger(edThisPage.Text) then exit;

  WB.OleObject.Document.ParentWindow.ScrollBy(0, StrToInt(edThisPage.Text) * WB.Height); //OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop := OleVariant(WB.Document as IHTMLDocument2).Body.ScrollTop + WB.Height;
  BerechneSeiten.Interval := 500;
end;

procedure TViewer.edThisPageEnter(Sender: TObject);
begin
  //Der Timer muss unterbrochen werden, sonst kann ich in diesem TEdit edThisPage nichts eingeben
  BerechneSeiten.Interval := 0;
end;

procedure TViewer.edThisPageExit(Sender: TObject);
begin
  //Bei Verlassen den Timer für die Seitenberechnung wieder starten
  BerechneSeiten.Interval := 500;
end;

//Die Kapitelsteuerung. Einfach den unsichtbaren weißen Text ekapitelx suchen
//falls vorher die Scrollbalken benutzt wurden, sorgt die Schleife dafür, dass
//eine ekapitel-Nr unterhab der aktuellen Seite i angesteuert wird
procedure TViewer.BtnKapitelPreviousClick(Sender: TObject);
var
  Range: IHTMLTxtRange;
  i: integer;
begin
  if WB.Busy then exit;
  i := thisPage;
  WB.Hide; //Anzeige wird solange unterbrochen

  repeat
    if KapitelNr > 2 then Dec(KapitelNr);
    Range := ((WB.Document as IHTMLDocument2).body as IHTMLBodyElement).createTextRange;
    if Range.findText('ekapitel' + IntToStr(KapitelNr), 1, 0) then Range.ScrollIntoView(True);
    BerechneSeitenTimer(Self);
  until (i >= thisPage);

  WB.Show;
end;

//Dasselbe rückwärts
procedure TViewer.BtnKapitelNextClick(Sender: TObject);
var
  Range: IHTMLTxtRange;
  i: integer;
begin
  if WB.Busy then exit;
  i := thisPage;
  WB.Hide;

  repeat
    if KapitelNr < High(cr.html) then Inc(KapitelNr);
    Range := ((WB.Document as IHTMLDocument2).body as IHTMLBodyElement).createTextRange;
    if Range.findText('ekapitel' + IntToStr(KapitelNr), 1, 0) then begin Range.ScrollIntoView(True); WB.OleObject.Document.ParentWindow.ScrollBy(0, WB.Height - 50); {Range.select} end;
    BerechneSeitenTimer(Self);
  until (i <= thisPage);

  WB.Show;
end;


//Da der TWebbrowser kein Scrollevent hat, wird einfach regelmäßig die Seitenzahl neu berechnet
procedure TViewer.BerechneSeitenTimer(Sender: TObject);
var
  px: TPoint;
begin
  PanelHide.Visible := false;
  WB_GetScrollPosition(WB, px);
  FirstVisibleLine := px.y;
  VisibleLines := WB.Height;
  MaxLines := ((WB.Document as IHTMLDocument2).body as IHTMLElement2).ScrollHeight;

  ThisPage := ((FirstVisibleLine+1) div VisibleLines) + 1; //+1 sonst 0 möglich
  MaxPage := MaxLines div VisibleLines;
  edThisPage.Text := IntToStr(ThisPage);

  lbSeite.Caption := 'Seite ' + IntToStr(ThisPage) + ' von ' + IntToStr(MaxPage); //'Seiten: ' + IntToStr(MaxPage); //lbSeite.Caption :=
end;

//temporäre Dateien wieder löschen
procedure TViewer.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  WB.Navigate('about:blank');

  //Webbrowser geleert. Jetzt temporäre Daten löschen. Achtung: Verzeichnis ohne Backslash! = ExcludeTrailingPathDelimiter(tempPath)
  While (WB.ReadyState < READYSTATE_INTERACTIVE) do Application.ProcessMessages;
  if Assigned(WB.Document) then if deldir(ExcludeTrailingPathDelimiter(tempPath)) = false then ShowMessage('Temporäre Dateien in ' + tempPath + ' konnten nicht entfernt werden.'+#13+'Bitte manuell löschen');
end;


{***********************************************************************************************}
{*  Webbrowser Scrollbar X,Y Position ermitteln:                                               *} 
{*  Quelle: http://www.delphipraxis.net/110089-twebbrowser-scrollposition-ermitteln.html       *}
{***********************************************************************************************}
function TViewer.WB_GetScrollPosition(WB: TWebBrowser; var ScrollPos: TPoint): Boolean;

  // Scrollbar X,Y Position der ListView ermitteln
  function WB_GetLVScrollPosition(WB: TWebBrowser; var ScrollPos: TPoint): Boolean;
  var lpsi: TScrollInfo; WND, WndLV: HWND; begin Result := False; {SysListView32 Child vom TWebBrowser suchen} WndLV := 0; Wnd := GetNextWindow(WB.Handle, GW_CHILD); while (WndLV = 0) and (WND <> 0) do begin WndLV := FindWindowEx(Wnd, 0, 'SysListView32', nil); Wnd := GetNextWindow(Wnd, GW_CHILD) end; if WndLV <> 0 then {SysListView32 gefunden} begin {TScrollInfo initialisieren} FillChar(lpsi, SizeOf(lpsi), 0); with lpsi do begin cbSize := SizeOf(lpsi); fMask := SIF_POS; end; {ScrollInfos der vertikalen ScrollBar ermitteln} if GetScrollInfo(WndLV, SB_VERT, lpsi) then begin ScrollPos.Y := lpsi.nPos; {ScrollInfos der horizontalen ScrollBar ermitteln} if GetScrollInfo(WndLV, SB_HORZ, lpsi) then begin ScrollPos.X := lpsi.nPos; Result := True; end; end; end; end;

  // Scrollbar X,Y Position des HTML Documents ermitteln
  function WB_GetDOCScrollPosition(WB: TWebBrowser; var ScrollPos: TPoint): Boolean;
  var IDoc: IHTMLDocument2; IDoc3: IHTMLDocument3; IElement: IHTMLElement; begin ScrollPos := Point(-1, -1); Result := False; if Assigned(WB.Document) and (Succeeded(WB.Document.QueryInterface(IHTMLDocument2, IDoc))) then begin IDoc := WB.Document as IHTMLDocument2; if Assigned(IDoc) and Assigned((IHTMLDocument2(IDoc).Body)) then begin if (IDoc.QueryInterface(IHTMLDocument3, IDoc3) = S_OK) then if Assigned(IDoc3) then IElement := IDoc3.get_documentElement; if (Assigned(IElement)) and (Variant(IDoc).DocumentElement.scrollTop = 0) then ScrollPos.Y := IHTMLDocument2(IDoc).Body.getAttribute('ScrollTop', 0) else ScrollPos.Y := Variant(IDoc).DocumentElement.scrollTop; if Assigned(IElement) and (Variant(IDoc).DocumentElement.scrollLeft = 0) then ScrollPos.X := IHTMLDocument2(IDoc).Body.getAttribute('ScrollLeft', 0) else ScrollPos.X := Variant(IDoc).DocumentElement.scrollLeft end; Result := (ScrollPos.X <> -1) and (ScrollPos.Y <> -1) end; end;

begin
  Result := WB_GetDOCScrollPosition(WB, ScrollPos);
  if not Result then Result := WB_GetLVScrollPosition(WB, ScrollPos);
end;
{***********************************************************************************************}

function TViewer.IsInteger(str : String): Boolean;
var
  i: integer;
begin
  Result:=true;
  try
    i := StrToInt(str);
  except
    Result:=false;
  end;
end;


// mit deldir wird das temporäre entpackte Epub bei Beenden der Application wieder gelöscht
function TViewer.deldir(dir: string): Boolean;
var
  fos: TSHFileOpStruct;
begin
  ZeroMemory(@fos, SizeOf(fos));
  with fos do
  begin
    wFunc := FO_DELETE;
    fFlags := FOF_SILENT or FOF_NOCONFIRMATION;
    pFrom := PChar(dir + #0);
  end;
  Result := (0 = ShFileOperation(fos));
end;

end.
  Mit Zitat antworten Zitat
Jumpy

 
Delphi 6 Enterprise
 
#4
  Alt 6. Feb 2014, 09:07
Schau dir doch auch mal das FB2 Format an. Das ist glaub ich auch XML basiert. Ggf. kannste das in einem Abwasch mit behandeln und schon kann dein Reader ein Format mehr...
Ralph
  Mit Zitat antworten Zitat
Perlsau
 
#5
  Alt 31. Jul 2015, 23:06
Hallo Ralf, hab deinen Code mal etwas näher unter die Lupe genommen, weil ich eine Anwendung entwickle, die Epubs in einer Datenbank verwalten soll. Dabei ist mir folgende Ungereimtheit aufgefallen:

Delphi-Quellcode:
  try
   index := KAZip1.Entries.IndexOf(cr.opfFile);
   //auch hier habe ich die Datei content.opf in einen Stream cr.ms eingelesen.
   //Alternativ ist das epub-Archiv vielleicht aber auch schon entpackt
   KAZip1.ExtractToStream(KAZip1.Entries.Items[Index], cr.ms);
   if index < 0 then exit; // Test von index erst nach der Verwendung???
   KaZip1.Active := true;
   cr.ms.seek(0,sofromBeginning);
   //so kann man einen Stream in eine string-Variable übergeben. s bzw cr.opfContent wird aber nur zum testen benötigt
   SetString(s, PAnsiChar(cr.ms.Memory), cr.ms.Size); {set string to get memory}
   cr.opfContent := s;

   //hier wird der Stream in den Xml-Parser übergeben
   XmlDoc := TXMLDocument.Create(Application);
   XmlDoc.Active := True;
   XmlDoc.LoadFromStream(cr.ms);

   cr.ms.Clear;
  finally
  end;
Die Integer-Variable index wird erst nach ihrer Verwendung daraufhin überprüft, ob sie kleiner als 0 ist. Das halte ich für einen Fehler bzw. würde bei Nichtauffinden von "content.opf" (oder wie die Datei im Zip-Archiv auch heißen mag) eine Zugriffsverletzung auslösen, da ein Item mit einem Index kleiner 0 nicht existieren kann.

Des weiteren fand ich einen vollkommen unverständlichen Vergleich mit null im Abschnitt Metadata durchsuchen:
Delphi-Quellcode:
      if ChildNodes[h].NodeName = 'metathen
       if (ChildNodes[h].Attributes['name']<>null)
        and (ChildNodes[h].Attributes['name'] = 'cover')
         then cr.mcover := ChildNodes[h].Attributes['content'];
Wozu hier ein Vergleich mit null, der zudem einen Compilerfehler erzeugt (was soll das sein: null?), nötig sein sollte, wo doch anschließend sowieso getestet wird, ob der Attribut-Name mit dem String 'cover' identisch ist, kann ich nicht nachvollziehen. Wenn dort 'cover" drinsteht, kann es ja nicht leer sein, oder?

Im Abschnitt Guide reference durchsuchen kann ich nicht nachvollziehen, wieso mehrmals dieselben Childnodes abgerufen werden:
Delphi-Quellcode:
  for z := 0 to XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes.Count - 1 do
  begin
    shref := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['href'].Text;
    stitle := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['title'].Text;
    stype := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes['reference'].AttributeNodes['type'].Text;
  end;
Die Zähl-Variable z wird nirgendwo verwendet, um auf den nächsten Reference-Node zuzugreifen. Soweit ich das anhand meiner hier verfügbaren Epubs beurteilen kann, besitzt guide stets ein odere mehrere Child-Nodes namens reference, die jeweils drei Attribute enthalten. Müßte es dann nicht so geschrieben werden:
Delphi-Quellcode:
  for z := 0 to XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes.Count - 1 do
    if XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes[z].name = 'referenceThen
    begin
      shref := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes[z].AttributeNodes['href'].Text;
      stitle := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes[z].AttributeNodes['title'].Text;
      stype := XmlDoc.DocumentElement.ChildNodes['guide'].ChildNodes[z].AttributeNodes['type'].Text;
    end;
Schleierhaft ist mir auch, was mit den Strings, die den lokalen Variablen shref, stitle und stype zugewiesen wurden, geschehen soll. In deinem Code werden diese Variablen bis zum Ende der Procedure nicht mehr verwendet.

Ach ja, ich verwende selbstverständlich Abbrevia statt KaZip und bin gerade dabei, deine ellenlangen (und damit unübersichtlichen) Methoden in kleinere Häppchen auftzuteilen. Ansonsten: gute Arbeit (soweit ich das bislang zu beurteilen vermag) oder besser: Vorarbeit

Geändert von Perlsau ( 1. Aug 2015 um 01:07 Uhr) Grund: weitere Fehler gefunden ...
  Mit Zitat antworten Zitat
Ralf Stehle

 
Delphi 7 Professional
 
#6
  Alt 1. Aug 2015, 12:06
Danke für Deine Anregungen.

Ich bin absoluter Hobby-Programmierer, das Durchsuchen der Epubs war für mich eigentlich eine Nummer zu groß. Durch meinen Beitrag hoffte ich auf andere dafür zu interessieren.

Leider habe ich mich aus Zeitmangel schon länger nicht mehr mit dem Projekt beschäftigt und weiß nicht mehr, warum ich die zitierten Codezeilen so programmiert habe.
  Mit Zitat antworten Zitat
Perlsau
 
#7
  Alt 1. Aug 2015, 13:06
Okay, das ist eine ehrliche Antwort, die keines weiteren Kommentars bedarf ... ist ja auch schon bald 20 Monate her, daß du dich damit befaßt hast

Doch offenbar kennst du dich ein wenig mit XML-Bearbeitung aus. Falls ich mich hierin geirrt habe, sind natürlich auch andere User dazu aufgerufen, zur Lösung beizutragen ... Ich erhalte nämlich an der Stelle, wo ich die container.xml durchsuchen will, eine Zugriffsverletzung:

TextDataBase.exe ist eine Exception der Klasse EInvalidPointer mit der Meldung 'Ungültige Zeigeroperation' aufgetreten.

... und zwar an dieser Stelle:
cr.opfFile := XmlDoc.DocumentElement.ChildNodes['rootfiles'].ChildNodes['rootfile'].AttributeNodes['full-path'].Text; Bei mir sieht das natürlich ein wenig anders aus, ist aber im Grunde dasselbe:
Delphi-Quellcode:
Function TEpubs.GetMetaData : Boolean;
begin
  Inhalt.MemS.Seek(0,soFromBeginning);
  If Inhalt.MemS.Size = 0 Then
  Begin
    Result := False;
    GLD.Fehlertext := 'Stream für Container.xml ist leer - in "' + Inhalt.EpubDatei + '"';
  End ELse
  Try
    XmlDok.LoadFromStream(Inhalt.MemS);
    Inhalt.OpfFile := XmlDok.DocumentElement.ChildNodes['rootfiles'].ChildNodes['rootfile'].AttributeNodes['full-path'].Text;
    Inhalt.OpfDir := ExtractOpfDir(Inhalt.OpfFile);
    Result := True;
  Except
    On e:exception Do
    Begin
      Result := False;
      GLD.Fehlertext := 'Fehler beim Analysieren der Container.xml in "' + Inhalt.EpubDatei + '": ' + e.Message;
    End;
  End;
end;
Die Methode ist Teil einer Klasse, die bereits im Constructor alle benötigten Komponenten erzeugt (und selbstverständlich im Destructor wieder freigibt):
Delphi-Quellcode:
Constructor TEpubs.Create;
begin
  inherited;
  UnZip := TAbUnZipper.Create(Nil);
  XmlDok := TXMLDocument.Create(nil);
  XmlDok.DOMVendor := DOMVendors.Vendors[0];
  Inhalt.MemS := TMemoryStream.Create;
  LogList := TStringList.Create;
  If System.SysUtils.FileExists(GLD.URec.LogDatei) Then
     LogList.LoadFromFile(GLD.URec.LogDatei);
end;
Wieso löst der Zugriff auf die Childnodes von container.xml eine ungültige Zeigeroperation aus? Bin leider kein XML-Experte, was ich wohl ändern sollte ...
Die container.xml sieht ja bei allen Epubs ziemlich gleich aus, manchmal feht der Encoding-Hinweis, aber sonst:
Code:
  <?xml version="1.0" encoding="UTF-8" ?>
  - <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
    - <rootfiles>
        <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml" />
      </rootfiles>
    </container>
  Mit Zitat antworten Zitat
Perlsau
 
#8
  Alt 2. Aug 2015, 10:22
Das Problem mit dem ungültigen Zeiger beim Zugriff auf einen Knoten der XML-Struktur liegt offenbar darin, daß ich TXmlDocument keinen Owner zuweise, sondern nil verwende. Die – vorläufige – Lösung besteht darin, die ganze Klasse mit Owner zu erzeugen und diesen Owner dann beim Erzeugen der TXmlDocument-Instanz einzusetzen. Ob das so sein muß oder auch anders geht, weiß ich nicht und erhoffe mir eine Antwort in diesem Thread, den ich zu dieser speziellen XML-Thematik erstellt habe.
  Mit Zitat antworten Zitat
Themen-Optionen Tutorial durchsuchen
Tutorial durchsuchen:

Erweiterte Suche
Ansicht

Forumregeln

Es ist dir nicht erlaubt, neue Themen zu verfassen.
Es ist dir nicht erlaubt, auf Beiträge zu antworten.
Es ist dir nicht erlaubt, Anhänge hochzuladen.
Es ist dir nicht erlaubt, deine Beiträge zu bearbeiten.

BB-Code ist an.
Smileys sind an.
[IMG] Code ist an.
HTML-Code ist aus.
Trackbacks are an
Pingbacks are an
Refbacks are aus

Gehe zu:

Impressum · AGB · Datenschutz · Nach oben
Alle Zeitangaben in WEZ +1. Es ist jetzt 20:00 Uhr.
Powered by vBulletin® Copyright ©2000 - 2024, Jelsoft Enterprises Ltd.
LinkBacks Enabled by vBSEO © 2011, Crawlability, Inc.
Delphi-PRAXiS (c) 2002 - 2023 by Daniel R. Wolf, 2024 by Thomas Breitkreuz