Neuer Parser

This commit is contained in:
Simon Martens
2026-02-20 12:59:33 +01:00
parent a0e1d61f74
commit 1fa5f52eef
6 changed files with 1453 additions and 569 deletions

View File

@@ -3,14 +3,16 @@ package xmlmodels
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strconv"
)
type Letter struct {
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Pages []Page `xml:"page"`
Hands []RefElement `xml:"hand"`
Inner string `xml:",innerxml"`
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Hands []int `xml:"-"`
Data []Page
}
func (l Letter) Keys() []any {
@@ -29,7 +31,192 @@ func (l Letter) String() string {
return string(json)
}
type Page struct {
XMLName xml.Name `xml:"page"`
Index int `xml:"index,attr"`
// NOTE: parseSidenote und unten UnmarshalXML sind die beiden haupstächlichen Kontexte, in denen Text gehalten wird.
// Wir unterteilen Briefe in Brief - Seite - Zeilen und Sidenotes in Sidenote - Zeilen (weil eine Sidenote nicht über
// mehrere Seiten gehen kann).
// NOTE: Zeilen sind geschlossene Einheiten, die auch als HTML einen selbstständigen Block bilden können. Dazu werden
// in parseBlockLines synthetisch Elemente entweder am Anfang oder Ende der Zeile hinzugefügt, um einen offenen Stack
// zu schließen oder den Stack der vorhergehenden Zeile wieder zu öffnen, weil die Auszeichnugen fortgehen.
// NOTE: Wichtige synthetische Tags:
// - Am Beginn oder Ende einer Zeile, wenn der Kontext in der XML über die Zeilen geöffnet bleibt (Token.Synth = true)
// - Am Beginn von letterText und Sidenote kann eine synthetische erste Zeile eingefügt sein (Line.Type = First)
// - Am Beginn einer Seite kann eine eine Zeile eingefügt sein, wenn der Kontext beispielsweise eines offenen
// Absatzes über die Seitengrenze fortgeführt wird (Line.Type = Continuation)
// NOTE: Whitespace-Handling
// - Als Whitespace gilt hier nur ASCII-Whitespace, also TAB, LF, CR, SPACE. Alles andere kann semantisch bedeutsam sein.
// - Am Anfang von letterText, Sidenote oder Page: alle Whitespace-Token werden ignoriert, bis Text kommt
// - Am Anfang und Ende von Zeilen: alle Whitespace-Token werden ignoriert, bis Text bzw. die neue Zeile kommt.
func parseSidenote(dec *xml.Decoder, se xml.StartElement) (Sidenote, int, error) {
var sn Sidenote
pageNum := 0
for _, a := range se.Attr {
switch a.Name.Local {
case "pos":
sn.Position = a.Value
case "annotation":
sn.Annotation = a.Value
case "page":
if n, err := strconv.Atoi(trimASCIISpace(a.Value)); err == nil {
pageNum = n
}
}
}
lines, err := parseBlockLines(dec, "sidenote")
if err != nil {
return sn, pageNum, err
}
sn.Lines = lines
return sn, pageNum, nil
}
func (l *Letter) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
// INFO: Brifnummer extrahieren, main Loop below
for _, a := range start.Attr {
if a.Name.Local == "letter" {
n, err := strconv.Atoi(trimASCIISpace(a.Value))
if err != nil {
return fmt.Errorf("letterText@letter: %w", err)
}
l.Letter = n
break
}
}
var (
pages []Page
curPage *Page
)
ensurePage := func(num int) *Page {
for i := range pages {
if pages[i].Number == num {
return &pages[i]
}
}
pages = append(pages, Page{Number: num})
return &pages[len(pages)-1]
}
acc := newLineAccumulator(First, func(line Line) {
if curPage == nil {
curPage = ensurePage(1)
}
curPage.Lines = append(curPage.Lines, line)
})
handlePage := func(se xml.StartElement) error {
idx := 1
for _, a := range se.Attr {
if a.Name.Local == "index" {
n, err := strconv.Atoi(trimASCIISpace(a.Value))
if err != nil {
return fmt.Errorf("page@index: %w", err)
}
if n > 0 {
idx = n
}
break
}
}
if acc.curLine != nil {
acc.closeLine()
}
curPage = ensurePage(idx)
if acc.hasAnyLine {
acc.setImplicitType(Continuation)
} else {
acc.setImplicitType(First)
}
return nil
}
// INFO: Main Loop
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
name := t.Name.Local
if isTransparentWrapper(name) {
continue
}
switch name {
case "page":
if err := handlePage(t); err != nil {
return err
}
continue
case "line":
acc.handleLineMarker(t)
continue
case "sidenote":
sn, pageNum, err := parseSidenote(dec, t)
if err != nil {
return err
}
if pageNum == 0 {
if curPage != nil {
pageNum = curPage.Number
} else {
pageNum = 1
}
}
p := ensurePage(pageNum)
p.Sidenotes = append(p.Sidenotes, sn)
continue
}
acc.appendStart(name, attrsToMap(t.Attr))
case xml.EndElement:
name := t.Name.Local
if isTransparentWrapper(name) {
continue
}
// INFO: Exit-Bedingung
if name == start.Name.Local {
if acc.curLine != nil {
acc.closeLine()
}
l.Data = pages
return nil
}
// INFO: Selbst-schließende tags werden vom Go-Parser expandiert, deswegen:
if name == "page" || name == "line" {
continue
}
acc.appendEnd(name)
case xml.CharData:
s := string([]byte(t))
if isOnlyASCIISpace(s) {
if acc.isAtLineStart() {
continue
}
s = " "
}
acc.appendText(s)
}
}
l.Data = pages
return nil
}