Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/docextract/docextract.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/docextract/docextract.go |
| Original SHA-256 | f647c4853776e51b0b845d13de88d917788bdd5535046a03860501724eb3142c |
| Git blob SHA | 5caed37a88fcf36de89ae0ceb5d07529a30496c2 |
| Reading-copy SHA-256 | f647c4853776e51b0b845d13de88d917788bdd5535046a03860501724eb3142c |
Every recorded occurrence
These 15 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/platform/docextract/docextract.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
| Janady13/missionmatch / internal/platform/docextract/docextract.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓231 lines · 7.4 KB
// Package docextract turns a downloaded federal attachment into plain text for the deterministic
// mission and labor classifier. It is pure Go with no cgo and no network, so it builds under
// CGO_ENABLED=0 and GOFIPS140=v1.0.0 and never reaches a Python tool. It dispatches on the file
// extension: PDF through a pure Go reader, DOCX through the standard library zip and xml packages,
// HTML through the x/net tokenizer, and plain text (or any unknown extension) as the raw bytes.
//
// This package is imported ONLY by the federalsignal sync path, never by cmd/api. The PDF reader drags
// in stdlib crypto for encrypted PDF support (a dead path here, the attachments are public), so keeping
// it out of the serving binary keeps the API SBOM clean. See docs/STACK_DECISIONS.md.
package docextract
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"archive/zip"
"encoding/xml"
"golang.org/x/net/html"
"github.com/ledongthuc/pdf"
)
// maxTextBytes caps the extracted text so one oversized attachment can never blow up memory or the
// classifier. One megabyte of text is far more than any labor mix or mission keyword needs.
const maxTextBytes = 1 << 20
// ErrUnsupported names the unsupported file type condition. ExtractText itself is permissive: an
// unknown extension falls back to treating the bytes as plain text rather than failing, because a
// federal attachment with an odd extension is more useful mined than dropped. ErrUnsupported is
// exported so a stricter caller (or a future strict mode) can construct and branch on the typed error
// without redefining it.
var ErrUnsupported = errors.New("docextract: unsupported file type")
// ErrParse wraps any failure to parse a structurally broken file (a corrupt PDF, a non zip DOCX, and so
// on). Detect with errors.Is(err, ErrParse). The underlying cause is wrapped for diagnostics, the file
// bytes themselves are never echoed so no attachment content leaks into a log.
var ErrParse = errors.New("docextract: parse failed")
// ExtractText returns the plain text of data, dispatched on the lowercased extension of filename. It
// caps the result at maxTextBytes. On a corrupt or unsupported file it returns a typed error wrapping
// ErrParse or ErrUnsupported.
func ExtractText(ctx context.Context, filename string, data []byte) (string, error) {
ext := lowerExt(filename)
switch ext {
case ".pdf":
return capText(extractPDF(ctx, data))
case ".docx":
return capText(extractDOCX(data))
case ".htm", ".html":
return capText(extractHTML(data))
case ".txt", "":
return capText(string(data), nil)
default:
// Unknown extensions fall back to treating the bytes as text rather than failing, since a federal
// attachment with an odd extension (for example a .rtf saved as plain text) is more useful mined
// than dropped. A caller that needs strictness can compare the extension before calling.
return capText(string(data), nil)
}
}
// lowerExt returns the lowercased extension of name including the leading dot, or empty if none.
func lowerExt(name string) string {
i := strings.LastIndexByte(name, '.')
if i < 0 || i == len(name)-1 {
return ""
}
// Reject a dot that is part of a path segment with no real extension (for example a trailing slash).
if strings.ContainsAny(name[i+1:], "/\\") {
return ""
}
return strings.ToLower(name[i:])
}
// cap truncates s to maxTextBytes, on a UTF-8 rune boundary, and passes any error through unchanged.
func capText(s string, err error) (string, error) {
if err != nil {
return "", err
}
if len(s) <= maxTextBytes {
return s, nil
}
cut := maxTextBytes
// Back up to a rune boundary so the cap never splits a multibyte rune.
for cut > 0 && !utf8Start(s[cut]) {
cut--
}
return s[:cut], nil
}
// utf8Start reports whether b is the first byte of a UTF-8 sequence (not a continuation byte).
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }
// extractPDF reads every page's plain text from a PDF held entirely in memory.
func extractPDF(ctx context.Context, data []byte) (string, error) {
if len(data) == 0 {
return "", fmt.Errorf("%w: empty pdf", ErrParse)
}
r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
// Wrap with ErrParse only, the cause may carry structural detail but never the file bytes.
return "", fmt.Errorf("%w: pdf reader: %v", ErrParse, err)
}
rd, err := r.GetPlainText()
if err != nil {
return "", fmt.Errorf("%w: pdf text: %v", ErrParse, err)
}
// Bound the copy so a hostile or malformed PDF that claims a huge text length cannot exhaust memory.
var sb strings.Builder
if _, err := io.Copy(&sb, io.LimitReader(rd, maxTextBytes+1)); err != nil {
return "", fmt.Errorf("%w: pdf read: %v", ErrParse, err)
}
_ = ctx
return sb.String(), nil
}
// extractDOCX reads word/document.xml from the DOCX zip and joins the text runs with spaces.
func extractDOCX(data []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", fmt.Errorf("%w: docx zip: %v", ErrParse, err)
}
var doc *zip.File
for _, f := range zr.File {
if f.Name == "word/document.xml" {
doc = f
break
}
}
if doc == nil {
return "", fmt.Errorf("%w: docx missing word/document.xml", ErrParse)
}
rc, err := doc.Open()
if err != nil {
return "", fmt.Errorf("%w: docx open part: %v", ErrParse, err)
}
defer func() { _ = rc.Close() }()
dec := xml.NewDecoder(io.LimitReader(rc, 64<<20)) // bound the part so a zip bomb cannot run away
var sb strings.Builder
inText := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("%w: docx xml: %v", ErrParse, err)
}
switch t := tok.(type) {
case xml.StartElement:
// A WordprocessingML text run is the <w:t> element. Match on the local name so the namespace
// prefix (w) does not have to be hard coded.
if t.Name.Local == "t" {
inText = true
}
case xml.EndElement:
if t.Name.Local == "t" {
inText = false
sb.WriteByte(' ') // join adjacent runs with a space so words do not collide
}
case xml.CharData:
if inText {
sb.Write(t)
}
}
if sb.Len() > maxTextBytes {
break
}
}
return strings.TrimSpace(sb.String()), nil
}
// extractHTML returns the visible text of an HTML document, dropping the content of script and style
// elements and collapsing tags to whitespace.
func extractHTML(data []byte) (string, error) {
z := html.NewTokenizer(bytes.NewReader(data))
var sb strings.Builder
skipDepth := 0 // > 0 while inside a script or style subtree
for {
tt := z.Next()
if tt == html.ErrorToken {
if err := z.Err(); err != nil && err != io.EOF {
return "", fmt.Errorf("%w: html: %v", ErrParse, err)
}
break
}
switch tt {
case html.StartTagToken:
name, _ := z.TagName()
if isSkippable(name) {
skipDepth++
}
case html.EndTagToken:
name, _ := z.TagName()
if isSkippable(name) && skipDepth > 0 {
skipDepth--
}
case html.TextToken:
if skipDepth == 0 {
txt := strings.TrimSpace(string(z.Text()))
if txt != "" {
if sb.Len() > 0 {
sb.WriteByte(' ')
}
sb.WriteString(txt)
}
}
}
if sb.Len() > maxTextBytes {
break
}
}
return sb.String(), nil
}
// isSkippable reports whether an element's text content must be dropped (script and style carry code,
// never visible prose).
func isSkippable(name []byte) bool {
switch string(bytes.ToLower(name)) {
case "script", "style":
return true
default:
return false
}
}