Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

279 lines
8.7 KiB
Go

package languages
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"image"
"io"
"path"
"regexp"
"strconv"
"strings"
// Raster decoders registered for image.DecodeConfig — stdlib formats
// plus the x/image extras already vendored. DecodeConfig reads only
// the header, so this is cheap even for large images.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/ledongthuc/pdf"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// --- DCA10: multimodal ingest (PDF + images) -----------------------
//
// Code and markdown were the only file classes the graph ingested. The
// two extractors below make image assets and PDF documents first-class
// nodes: an image becomes a KindImage asset node carrying its format and
// dimensions, and a PDF becomes a KindFile node with one searchable
// KindDoc node per page, so a diagram or a spec PDF is discoverable
// alongside the code it documents.
// ImageAssetExtractor ingests raster / vector image files as graph nodes.
type ImageAssetExtractor struct{}
func NewImageAssetExtractor() *ImageAssetExtractor { return &ImageAssetExtractor{} }
func (e *ImageAssetExtractor) Language() string { return "image" }
func (e *ImageAssetExtractor) Extensions() []string {
return []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico", ".svg"}
}
func (e *ImageAssetExtractor) AssetClass() parser.AssetClass { return parser.AssetImage }
var _ parser.AssetExtractor = (*ImageAssetExtractor)(nil)
var svgOpenRe = regexp.MustCompile(`(?is)<svg\b[^>]*>`)
var svgAttrRe = regexp.MustCompile(`(?i)\b(width|height)\s*=\s*["']?\s*([0-9.]+)`)
var svgViewBoxRe = regexp.MustCompile(`(?is)\bviewBox\s*=\s*["']\s*[0-9.]+\s+[0-9.]+\s+([0-9.]+)\s+([0-9.]+)`)
func (e *ImageAssetExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
result := &parser.ExtractionResult{}
base := path.Base(filePath)
ext := strings.ToLower(path.Ext(filePath))
format := strings.TrimPrefix(ext, ".")
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, Language: "image",
}
result.Nodes = append(result.Nodes, fileNode)
sum := sha256.Sum256(src)
meta := map[string]any{
"asset_kind": "image",
"format": format,
"size_bytes": len(src),
"sha256": hex.EncodeToString(sum[:]),
}
var w, h int
if ext == ".svg" {
w, h = svgDimensions(src)
format = "svg"
} else if cfg, decFormat, err := image.DecodeConfig(bytes.NewReader(src)); err == nil {
w, h = cfg.Width, cfg.Height
if decFormat != "" {
format = decFormat
}
}
meta["format"] = format
if w > 0 {
meta["width"] = w
}
if h > 0 {
meta["height"] = h
}
imgID := "image::asset::" + filePath
result.Nodes = append(result.Nodes, &graph.Node{
ID: imgID, Kind: graph.KindImage, Name: base,
FilePath: filePath, StartLine: 1, Language: "image", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: filePath, To: imgID, Kind: graph.EdgeDefines, FilePath: filePath, Line: 1,
})
return result, nil
}
// svgDimensions parses an SVG's intrinsic size from the width / height
// attributes of its opening <svg> tag, falling back to the viewBox's
// w/h. Returns 0,0 when absent.
func svgDimensions(src []byte) (w, h int) {
tag := svgOpenRe.Find(src)
if tag == nil {
return 0, 0
}
for _, m := range svgAttrRe.FindAllSubmatch(tag, -1) {
v := atoiFloor(string(m[2]))
switch strings.ToLower(string(m[1])) {
case "width":
if w == 0 {
w = v
}
case "height":
if h == 0 {
h = v
}
}
}
if w == 0 || h == 0 {
if m := svgViewBoxRe.FindSubmatch(tag); m != nil {
if w == 0 {
w = atoiFloor(string(m[1]))
}
if h == 0 {
h = atoiFloor(string(m[2]))
}
}
}
return w, h
}
// atoiFloor parses the leading integer part of a numeric string, ignoring
// a fractional part and any trailing unit (e.g. "48px" -> 48, "12.5" -> 12).
func atoiFloor(s string) int {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
n, _ := strconv.Atoi(s[:end])
return n
}
// PDFExtractor ingests PDF documents: one KindFile node carrying page
// count + size, plus one KindDoc node per page whose extracted text feeds
// the prose search index.
type PDFExtractor struct{}
func NewPDFExtractor() *PDFExtractor { return &PDFExtractor{} }
func (e *PDFExtractor) Language() string { return "pdf" }
func (e *PDFExtractor) Extensions() []string { return []string{".pdf"} }
func (e *PDFExtractor) AssetClass() parser.AssetClass { return parser.AssetDocument }
// pdfPageTextCap bounds the per-page text stored on a KindDoc node so a
// large PDF can't bloat the graph; the head of each page is enough for
// prose search to locate the right document.
const pdfPageTextCap = 4000
// compile-time guarantee that PDFExtractor offers the streaming route the
// indexer prefers (one page at a time, never the whole file in memory), and
// that it is gated as a document asset.
var (
_ parser.StreamingExtractor = (*PDFExtractor)(nil)
_ parser.AssetExtractor = (*PDFExtractor)(nil)
)
func (e *PDFExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
result := &parser.ExtractionResult{}
sum := sha256.Sum256(src)
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, Language: "pdf",
Meta: map[string]any{
"asset_kind": "pdf",
"data_class": "content",
"size_bytes": len(src),
"sha256": hex.EncodeToString(sum[:]),
},
}
result.Nodes = append(result.Nodes, fileNode)
// The PDF reader can panic on malformed / encrypted documents — keep
// extraction failures from killing the whole index by recovering and
// returning just the file node.
func() {
defer func() { _ = recover() }()
r, err := pdf.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil || r == nil {
return
}
pdfEmitPages(filePath, r, fileNode, func(n *graph.Node, edges []*graph.Edge) {
result.Nodes = append(result.Nodes, n)
result.Edges = append(result.Edges, edges...)
})
}()
return result, nil
}
// ExtractStream implements parser.StreamingExtractor: it reads the PDF through
// the supplied io.ReaderAt one page at a time, so a large document is never
// held whole in memory. It emits one KindFile node plus one KindDoc node per
// page that has extractable text. The sha256 carried by the byte-path Extract
// is omitted here — hashing would require a full read, defeating the stream.
func (e *PDFExtractor) ExtractStream(filePath string, r io.ReaderAt, size int64, emit func(*graph.Node, []*graph.Edge)) error {
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, Language: "pdf",
Meta: map[string]any{
"asset_kind": "pdf",
"data_class": "content",
"size_bytes": int(size),
},
}
emit(fileNode, nil)
// The PDF reader can panic on malformed / encrypted documents — keep
// extraction failures from killing the pass by recovering and emitting
// just the file node.
func() {
defer func() { _ = recover() }()
rd, err := pdf.NewReader(r, size)
if err != nil || rd == nil {
return
}
pdfEmitPages(filePath, rd, fileNode, emit)
}()
return nil
}
// pdfEmitPages walks every page of r, emitting one capped KindDoc node (plus
// its defines edge from the file) per page that has extractable text. It stamps
// fileNode.Meta["pages"] as a side effect. Shared by Extract and ExtractStream.
func pdfEmitPages(filePath string, r *pdf.Reader, fileNode *graph.Node, emit func(*graph.Node, []*graph.Edge)) {
pages := r.NumPage()
if pages > 0 {
fileNode.Meta["pages"] = pages
}
for i := 1; i <= pages; i++ {
text := pdfPageText(r, i)
if text == "" {
continue
}
if len(text) > pdfPageTextCap {
text = text[:pdfPageTextCap]
}
pageID := filePath + "::doc:page-" + strconv.Itoa(i)
pageNode := &graph.Node{
ID: pageID, Kind: graph.KindDoc, Name: path.Base(filePath) + " p." + strconv.Itoa(i),
FilePath: filePath, StartLine: i, Language: "pdf",
Meta: map[string]any{"asset_kind": "pdf_page", "data_class": "content", "page": i, "section_text": text},
}
emit(pageNode, []*graph.Edge{{
From: filePath, To: pageID, Kind: graph.EdgeDefines, FilePath: filePath, Line: i,
}})
}
}
// pdfPageText extracts and whitespace-collapses one page's plain text,
// recovering from the reader's panics on a per-page basis.
func pdfPageText(r *pdf.Reader, page int) (text string) {
defer func() { _ = recover() }()
p := r.Page(page)
if p.V.IsNull() {
return ""
}
raw, err := p.GetPlainText(nil)
if err != nil {
return ""
}
return strings.Join(strings.Fields(raw), " ")
}