mirror of
https://github.com/hanzoai/semantic.git
synced 2026-10-30 15:04:19 +00:00
The package doc said the Office formats needed decoders outside the standard library. They do not: OOXML is a zip of XML parts, and archive/zip and encoding/xml read it. Three readers now do, sharing one package walker and one generic helper, part[T], that decodes a named part into whatever shape the reader needs and reports a missing optional part as fs.ErrNotExist. - docx: the body in order; a heading, by outline level or built-in heading style (inherited through basedOn), opens a section at its level; a table is records of "column: value" lines named by its first row; deleted and moved-away revisions are not read. - pptx: one section per slide in the order the deck shows them, titled by the title placeholder, tables as records, speaker notes kept, slide numbers and footers left out. - xlsx: one section per sheet, shared and rich strings resolved, and a number in a date format written as the date: RFC 3339 full-date, partial-time, or the two joined by T, with no zone because a workbook records none. The 1900 system's phantom 29 February and the 1904 system are both honoured. All three return the core properties as tags. A part whose stated size exceeds 256 MiB is refused before it is inflated, and a tree nested past 10,000 elements is refused rather than recursed off the stack. .doc, .xls and .ppt were mapped onto the OOXML names, so a compound file went to a zip reader. They are now their own placeholders and fail with ErrFormat naming what they are. Detect also recognises a document with no extension by its bytes: %PDF-, or a zip whose main part lives under word/, xl/ or ppt/. Co-authored-by: Hanzo Dev <[email protected]>
240 lines
9.2 KiB
Go
240 lines
9.2 KiB
Go
// Command parse reads seven formats — markdown, HTML, JSON, CSV, XML, email and
|
|
// a Word document — and shows what each one recovers beyond the text: the
|
|
// sections a splitter can cut on, the links, the document's own metadata, and
|
|
// the decoded value for the formats that have one.
|
|
//
|
|
// Parsing never opens a file. Turning a source into a document is ingest's job;
|
|
// parse reads Doc.Text and returns a document with a copy of Meta, so the
|
|
// origin the reader recorded survives.
|
|
//
|
|
// go run ./examples/parse
|
|
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/semantic"
|
|
"github.com/hanzoai/semantic/parse"
|
|
)
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
|
|
markdown(ctx)
|
|
html(ctx)
|
|
structured(ctx)
|
|
mail(ctx)
|
|
word(ctx)
|
|
detect(ctx)
|
|
missing(ctx)
|
|
}
|
|
|
|
// markdown keeps the source as the author wrote it and reports the structure
|
|
// the source declares. A heading inside a fenced code block is code, not
|
|
// structure, and the last section here holds that down.
|
|
func markdown(ctx context.Context) {
|
|
const src = "---\ntitle: Field notes\nauthor: Ada Lovelace\n---\n\n" +
|
|
"# Field notes\n\nBabbage Engines was founded by [Charles Babbage](https://example.org/cb).\n\n" +
|
|
"## Location\n\nHeadquartered in Montréal.\n\n" +
|
|
"## Code\n\n```\n# not a heading\n```\n"
|
|
|
|
d := say(ctx, parse.Markdown{}, semantic.Doc{ID: "notes", Source: "notes.md", Text: src})
|
|
fmt.Println("markdown")
|
|
fmt.Printf(" title %q\n", parse.Title(d))
|
|
fmt.Printf(" tags %v\n", parse.Tags(d))
|
|
for _, s := range parse.Sections(d) {
|
|
fmt.Printf(" section h%d %-10q %d..%d\n", s.Level, s.Title, s.Start, s.End)
|
|
}
|
|
for _, l := range parse.Links(d) {
|
|
fmt.Printf(" link %q → %s\n", l.Text, l.URL)
|
|
}
|
|
}
|
|
|
|
// html keeps what the markup meant and drops what it was for. Script and style
|
|
// content is not prose and is not read as prose.
|
|
func html(ctx context.Context) {
|
|
const src = `<html><head><title>Babbage Engines</title>` +
|
|
`<meta name="description" content="An engine works"></head>` +
|
|
`<body><script>window.x=1</script><h1>About</h1>` +
|
|
`<p>Founded by <a href="/people/cb">Charles Babbage</a>.</p>` +
|
|
`<img src="/img/mill.png" alt="the mill"></body></html>`
|
|
|
|
d := say(ctx, parse.HTML{Base: "https://example.org"}, semantic.Doc{ID: "about", Source: "about.html", Text: src})
|
|
fmt.Println("\nhtml")
|
|
fmt.Printf(" title %q\n", parse.Title(d))
|
|
fmt.Printf(" tags %v\n", parse.Tags(d))
|
|
fmt.Printf(" text %q\n", oneline(d.Text))
|
|
fmt.Printf(" script kept out of the text: %v\n", !strings.Contains(d.Text, "window.x"))
|
|
for _, l := range parse.Links(d) {
|
|
fmt.Printf(" link %q → %s\n", l.Text, l.URL)
|
|
}
|
|
for _, l := range parse.Images(d) {
|
|
fmt.Printf(" image %q → %s\n", l.Text, l.URL)
|
|
}
|
|
}
|
|
|
|
// structured shows the three formats that decode to a value. A grid of bare
|
|
// cells says nothing about what the cells mean, so each one is also rendered
|
|
// as "name: value" lines — which is what gives a splitter and an extractor
|
|
// something to read.
|
|
func structured(ctx context.Context) {
|
|
fmt.Println("\njson")
|
|
d := say(ctx, parse.JSON{}, semantic.Doc{ID: "co", Source: "co.json", Text: `
|
|
{"company":{"name":"Babbage Engines","founded":1843,"sites":["London","Montréal"]}}`})
|
|
fmt.Printf(" text %s\n", oneline(d.Text))
|
|
fmt.Printf(" leaves %v\n", parse.Flatten(parse.Data(d)))
|
|
|
|
fmt.Println("\ncsv")
|
|
d = say(ctx, parse.CSV{}, semantic.Doc{ID: "roster", Source: "roster.csv",
|
|
Text: "name,role,city\nAda Lovelace,analyst,Montréal\nCharles Babbage,founder,London\n"})
|
|
rows, _ := parse.Data(d).([]map[string]string)
|
|
fmt.Printf(" fields %v\n", parse.Fields(d))
|
|
fmt.Printf(" rows %d, one section each: %d\n", len(rows), len(parse.Sections(d)))
|
|
fmt.Printf(" row 1 %v\n", rows[0])
|
|
|
|
fmt.Println("\nxml")
|
|
d = say(ctx, parse.XML{}, semantic.Doc{ID: "cat", Source: "cat.xml", Text: `
|
|
<catalog><engine id="dm"><name>Difference Engine</name></engine>` +
|
|
`<engine id="am"><name>Analytical Engine</name></engine></catalog>`})
|
|
root, _ := parse.Data(d).(*parse.Node)
|
|
fmt.Printf(" root %s with %d children\n", root.Name, len(root.Nodes))
|
|
for _, n := range root.Find("name") {
|
|
fmt.Printf(" name %q\n", n.Text)
|
|
}
|
|
fmt.Printf(" text %s\n", oneline(d.Text))
|
|
}
|
|
|
|
// mail reads a message: the headers become tags with their encoded words
|
|
// decoded, and a multipart/alternative is read once, in its plainest part.
|
|
func mail(ctx context.Context) {
|
|
const src = "From: [email protected]\r\n" +
|
|
"To: [email protected]\r\n" +
|
|
"Subject: =?utf-8?q?Montr=C3=A9al_office?=\r\n" +
|
|
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
|
|
"Babbage Engines is headquartered in Montréal.\r\n"
|
|
|
|
d := say(ctx, parse.Email{}, semantic.Doc{ID: "msg", Source: "msg.eml", Text: src})
|
|
fmt.Println("\nemail")
|
|
fmt.Printf(" subject %q\n", parse.Title(d))
|
|
fmt.Printf(" from %s → %s\n", parse.Tags(d)["from"], parse.Tags(d)["to"])
|
|
fmt.Printf(" body %q\n", oneline(d.Text))
|
|
}
|
|
|
|
// word reads a .docx, which is a zip of XML parts: archive/zip and
|
|
// encoding/xml are all it takes. Headings become sections at their level and a
|
|
// table becomes records named by its first row. The document is built here,
|
|
// in memory, the way Word lays one out; ingest hands parse a real one's bytes
|
|
// unchanged, so the call is the same.
|
|
func word(ctx context.Context) {
|
|
const w = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`
|
|
p := func(style, text string) string {
|
|
if style != "" {
|
|
style = `<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`
|
|
}
|
|
return `<w:p>` + style + `<w:r><w:t>` + text + `</w:t></w:r></w:p>`
|
|
}
|
|
cell := func(text string) string { return `<w:tc>` + p("", text) + `</w:tc>` }
|
|
var buf bytes.Buffer
|
|
z := zip.NewWriter(&buf)
|
|
for _, part := range [][2]string{
|
|
{"_rels/.rels", `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
|
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ` +
|
|
`Target="word/document.xml"/></Relationships>`},
|
|
{"word/document.xml", `<w:document ` + w + `><w:body>` +
|
|
p("Heading1", "Staff") + p("", "Babbage Engines employs two people.") +
|
|
`<w:tbl><w:tr>` + cell("Name") + cell("Role") + `</w:tr>` +
|
|
`<w:tr>` + cell("Ada Lovelace") + cell("analyst") + `</w:tr>` +
|
|
`<w:tr>` + cell("Charles Babbage") + cell("founder") + `</w:tr></w:tbl>` +
|
|
p("Heading2", "Sites") + p("", "London and Montréal.") +
|
|
`</w:body></w:document>`},
|
|
} {
|
|
f, err := z.Create(part[0])
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if _, err := f.Write([]byte(part[1])); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
if err := z.Close(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
d := say(ctx, parse.Any{}, semantic.Doc{ID: "staff", Source: "staff.docx", Text: buf.String()})
|
|
fmt.Println("\ndocx")
|
|
fmt.Printf(" format %v, from %d zipped bytes\n", d.Meta["format"], buf.Len())
|
|
for _, s := range parse.Sections(d) {
|
|
fmt.Printf(" section h%d %-10q %q\n", s.Level, s.Title, oneline(s.Text(d)))
|
|
}
|
|
}
|
|
|
|
// detect is the one parser a pipeline over a mixed corpus needs: it takes the
|
|
// format from what the ingester recorded, and otherwise from the source's
|
|
// extension and the shape of the text.
|
|
func detect(ctx context.Context) {
|
|
fmt.Println("\ndetect")
|
|
for _, d := range []semantic.Doc{
|
|
{Source: "notes.md", Text: "# Notes\n"},
|
|
{Source: "page.html", Text: "<p>hello</p>"},
|
|
{Source: "unnamed", Text: `{"a":1}`},
|
|
{Source: "unnamed", Text: "a,b\n1,2\n"},
|
|
{Source: "unnamed", Text: "just prose"},
|
|
} {
|
|
got := say(ctx, parse.Any{}, d)
|
|
fmt.Printf(" %-14q → %-8s (Detect says %s)\n", oneline(d.Text), got.Meta["format"], parse.Detect(d))
|
|
}
|
|
fmt.Printf(" registered: %v\n", parse.Names())
|
|
}
|
|
|
|
// missing is the seam for a format this package names but does not read. pdf
|
|
// and the legacy binary Office formats — doc, xls, ppt — are registered as
|
|
// placeholders that report ErrFormat, so a caller can tell "this build cannot"
|
|
// from "nobody has heard of it" — and supplying a reader is one Register call.
|
|
func missing(ctx context.Context) {
|
|
fmt.Println("\nformats without a decoder")
|
|
d := semantic.Doc{ID: "report", Source: "report.pdf", Text: "%PDF-1.7"}
|
|
if _, err := (parse.Any{}).Parse(ctx, d); errors.Is(err, parse.ErrFormat) {
|
|
fmt.Printf(" report.pdf → %v\n", err)
|
|
}
|
|
|
|
parse.Register("pdf", pages{})
|
|
got := say(ctx, parse.Any{}, d)
|
|
fmt.Printf(" after Register(\"pdf\", …) → %q\n", got.Text)
|
|
}
|
|
|
|
// pages stands in for a PDF reader. A real one decodes the file; what matters
|
|
// here is that it is registered under the same name and reached by the same
|
|
// call as everything above.
|
|
type pages struct{}
|
|
|
|
func (pages) Parse(_ context.Context, d semantic.Doc) (semantic.Doc, error) {
|
|
d.Meta = map[string]any{"format": "pdf", "pages": 1}
|
|
d.Text = "whatever a pdf reader would return"
|
|
return d, nil
|
|
}
|
|
|
|
// say parses a document and stops on an error, since every document here is
|
|
// written a few lines above the call.
|
|
func say(ctx context.Context, f parse.Format, d semantic.Doc) semantic.Doc {
|
|
got, err := f.Parse(ctx, d)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// oneline renders text on a single line so a listing stays a listing.
|
|
func oneline(s string) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) > 96 {
|
|
return s[:96] + "…"
|
|
}
|
|
return s
|
|
}
|