v3 candidate
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/transforms"
|
||||
)
|
||||
|
||||
const MaxDecodedStreamOutput = 64 << 20
|
||||
|
||||
// ApplyFilter decodes one PDF stream filter. When a predictor is in use
|
||||
// it must be applied separately after this function returns raw filter
|
||||
// output; see ApplyPredictor.
|
||||
func ApplyFilter(name string, in []byte) ([]byte, error) {
|
||||
switch name {
|
||||
case "FlateDecode", "Fl":
|
||||
if out, err := transforms.InflateZlib(in); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
return transforms.InflateRaw(in)
|
||||
case "ASCIIHexDecode", "AHx":
|
||||
return decodeASCIIHex(in)
|
||||
case "ASCII85Decode", "A85":
|
||||
return decodeASCII85(in)
|
||||
case "RunLengthDecode", "RL":
|
||||
return decodeRunLength(in)
|
||||
case "LZWDecode", "LZW":
|
||||
return decodeLZW(in)
|
||||
case "CCITTFaxDecode", "CCF":
|
||||
return nil, errors.New("CCITTFaxDecode not supported (returns raw bytes)")
|
||||
case "JBIG2Decode":
|
||||
return nil, errors.New("JBIG2Decode not supported")
|
||||
case "DCTDecode", "DCT":
|
||||
// JPEG payload — return as-is, caller can save as .jpg.
|
||||
return in, nil
|
||||
case "JPXDecode":
|
||||
return in, nil
|
||||
case "Crypt":
|
||||
return nil, errors.New("Crypt filter not supported")
|
||||
}
|
||||
return nil, fmt.Errorf("unknown filter %q", name)
|
||||
}
|
||||
|
||||
// ApplyPredictor undoes the PNG-family predictor used with Flate/LZW. If
|
||||
// predictor <= 1 the data is returned unchanged.
|
||||
func ApplyPredictor(in []byte, predictor, columns, colors, bits int) ([]byte, error) {
|
||||
if predictor <= 1 {
|
||||
return in, nil
|
||||
}
|
||||
if len(in) > MaxDecodedStreamOutput {
|
||||
return nil, fmt.Errorf("predictor input exceeds %d byte limit", MaxDecodedStreamOutput)
|
||||
}
|
||||
bytesPerPixel, rowLen, err := predictorGeometry(columns, colors, bits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case predictor == 2:
|
||||
// TIFF predictor — apply left delta per sample.
|
||||
out := make([]byte, len(in))
|
||||
copy(out, in)
|
||||
step := bytesPerPixel
|
||||
for i := step; i < len(out); i++ {
|
||||
if i%rowLen < step {
|
||||
continue
|
||||
}
|
||||
out[i] = out[i] + out[i-step]
|
||||
}
|
||||
return out, nil
|
||||
case predictor >= 10 && predictor <= 15:
|
||||
// PNG predictors — each row is prefixed with a filter-type byte.
|
||||
stride := rowLen + 1
|
||||
out := make([]byte, 0, len(in))
|
||||
var prev []byte
|
||||
for i := 0; i < len(in); i += stride {
|
||||
if i+stride > len(in) {
|
||||
break
|
||||
}
|
||||
filterType := in[i]
|
||||
row := make([]byte, rowLen)
|
||||
copy(row, in[i+1:i+stride])
|
||||
switch filterType {
|
||||
case 0: // None
|
||||
case 1: // Sub
|
||||
for j := bytesPerPixel; j < rowLen; j++ {
|
||||
row[j] += row[j-bytesPerPixel]
|
||||
}
|
||||
case 2: // Up
|
||||
if prev != nil {
|
||||
for j := 0; j < rowLen; j++ {
|
||||
row[j] += prev[j]
|
||||
}
|
||||
}
|
||||
case 3: // Average
|
||||
for j := 0; j < rowLen; j++ {
|
||||
left := byte(0)
|
||||
if j >= bytesPerPixel {
|
||||
left = row[j-bytesPerPixel]
|
||||
}
|
||||
up := byte(0)
|
||||
if prev != nil {
|
||||
up = prev[j]
|
||||
}
|
||||
row[j] += byte((int(left) + int(up)) / 2)
|
||||
}
|
||||
case 4: // Paeth
|
||||
for j := 0; j < rowLen; j++ {
|
||||
a := 0
|
||||
if j >= bytesPerPixel {
|
||||
a = int(row[j-bytesPerPixel])
|
||||
}
|
||||
b := 0
|
||||
if prev != nil {
|
||||
b = int(prev[j])
|
||||
}
|
||||
c := 0
|
||||
if prev != nil && j >= bytesPerPixel {
|
||||
c = int(prev[j-bytesPerPixel])
|
||||
}
|
||||
p := a + b - c
|
||||
pa := abs(p - a)
|
||||
pb := abs(p - b)
|
||||
pc := abs(p - c)
|
||||
pred := 0
|
||||
switch {
|
||||
case pa <= pb && pa <= pc:
|
||||
pred = a
|
||||
case pb <= pc:
|
||||
pred = b
|
||||
default:
|
||||
pred = c
|
||||
}
|
||||
row[j] += byte(pred)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("PNG predictor: invalid filter type %d", filterType)
|
||||
}
|
||||
out = append(out, row...)
|
||||
prev = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("predictor %d not supported", predictor)
|
||||
}
|
||||
|
||||
func predictorGeometry(columns, colors, bits int) (int, int, error) {
|
||||
if columns <= 0 || colors <= 0 {
|
||||
return 0, 0, errors.New("predictor columns and colors must be positive")
|
||||
}
|
||||
switch bits {
|
||||
case 1, 2, 4, 8, 16:
|
||||
default:
|
||||
return 0, 0, fmt.Errorf("predictor bits per component %d is invalid", bits)
|
||||
}
|
||||
pixelBits := int64(colors) * int64(bits)
|
||||
rowBits := int64(columns) * pixelBits
|
||||
if pixelBits <= 0 || rowBits <= 0 || rowBits > int64(MaxDecodedStreamOutput)*8 {
|
||||
return 0, 0, errors.New("predictor dimensions exceed resource limits")
|
||||
}
|
||||
return int((pixelBits + 7) / 8), int((rowBits + 7) / 8), nil
|
||||
}
|
||||
|
||||
func ensureDecodedSize(current, additional int) error {
|
||||
if additional < 0 || current > MaxDecodedStreamOutput-additional {
|
||||
return fmt.Errorf("decoded stream exceeds %d byte limit", MaxDecodedStreamOutput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ASCIIHexDecode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeASCIIHex(in []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(in)/2)
|
||||
hi := -1
|
||||
for _, b := range in {
|
||||
switch {
|
||||
case b >= '0' && b <= '9':
|
||||
b = b - '0'
|
||||
case b >= 'a' && b <= 'f':
|
||||
b = b - 'a' + 10
|
||||
case b >= 'A' && b <= 'F':
|
||||
b = b - 'A' + 10
|
||||
case b == '>':
|
||||
if hi >= 0 {
|
||||
out = append(out, byte(hi)<<4)
|
||||
}
|
||||
return out, nil
|
||||
case isPDFWhitespace(b):
|
||||
continue
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if hi < 0 {
|
||||
hi = int(b)
|
||||
} else {
|
||||
out = append(out, byte(hi)<<4|b)
|
||||
hi = -1
|
||||
}
|
||||
}
|
||||
if hi >= 0 {
|
||||
out = append(out, byte(hi)<<4)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isPDFWhitespace(b byte) bool {
|
||||
return b == 0 || b == '\t' || b == '\n' || b == '\f' || b == '\r' || b == ' '
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ASCII85Decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeASCII85(in []byte) ([]byte, error) {
|
||||
// Strip optional <~ ... ~> framing and whitespace.
|
||||
start := 0
|
||||
if bytes.HasPrefix(in, []byte("<~")) {
|
||||
start = 2
|
||||
}
|
||||
out := make([]byte, 0, len(in))
|
||||
var group [5]byte
|
||||
n := 0
|
||||
for i := start; i < len(in); i++ {
|
||||
c := in[i]
|
||||
if isPDFWhitespace(c) {
|
||||
continue
|
||||
}
|
||||
if c == '~' {
|
||||
break
|
||||
}
|
||||
if c == 'z' && n == 0 {
|
||||
if err := ensureDecodedSize(len(out), 4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, 0, 0, 0, 0)
|
||||
continue
|
||||
}
|
||||
if c < '!' || c > 'u' {
|
||||
return nil, fmt.Errorf("ASCII85: invalid byte 0x%02x", c)
|
||||
}
|
||||
group[n] = c - '!'
|
||||
n++
|
||||
if n == 5 {
|
||||
v := uint32(group[0])*85*85*85*85 +
|
||||
uint32(group[1])*85*85*85 +
|
||||
uint32(group[2])*85*85 +
|
||||
uint32(group[3])*85 +
|
||||
uint32(group[4])
|
||||
if err := ensureDecodedSize(len(out), 4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
|
||||
n = 0
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
for i := n; i < 5; i++ {
|
||||
group[i] = 84
|
||||
}
|
||||
v := uint32(group[0])*85*85*85*85 +
|
||||
uint32(group[1])*85*85*85 +
|
||||
uint32(group[2])*85*85 +
|
||||
uint32(group[3])*85 +
|
||||
uint32(group[4])
|
||||
full := []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}
|
||||
if err := ensureDecodedSize(len(out), n-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, full[:n-1]...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RunLengthDecode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeRunLength(in []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(in))
|
||||
i := 0
|
||||
for i < len(in) {
|
||||
b := in[i]
|
||||
i++
|
||||
switch {
|
||||
case b == 128:
|
||||
return out, nil
|
||||
case b < 128:
|
||||
n := int(b) + 1
|
||||
if i+n > len(in) {
|
||||
return nil, errors.New("RunLength: truncated literal")
|
||||
}
|
||||
if err := ensureDecodedSize(len(out), n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, in[i:i+n]...)
|
||||
i += n
|
||||
default:
|
||||
n := 257 - int(b)
|
||||
if i >= len(in) {
|
||||
return nil, errors.New("RunLength: truncated run")
|
||||
}
|
||||
if err := ensureDecodedSize(len(out), n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k := 0; k < n; k++ {
|
||||
out = append(out, in[i])
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LZWDecode (variable-width, early-change = 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeLZW(in []byte) ([]byte, error) {
|
||||
const (
|
||||
clearCode = 256
|
||||
eodCode = 257
|
||||
)
|
||||
reader := &bitReader{data: in}
|
||||
table := make([][]byte, 258)
|
||||
for i := 0; i < 256; i++ {
|
||||
table[i] = []byte{byte(i)}
|
||||
}
|
||||
codeWidth := 9
|
||||
out := []byte{}
|
||||
prev := []byte(nil)
|
||||
for {
|
||||
c, err := reader.read(codeWidth)
|
||||
if err != nil {
|
||||
return out, nil
|
||||
}
|
||||
if c == eodCode {
|
||||
return out, nil
|
||||
}
|
||||
if c == clearCode {
|
||||
table = table[:258]
|
||||
codeWidth = 9
|
||||
prev = nil
|
||||
continue
|
||||
}
|
||||
var entry []byte
|
||||
switch {
|
||||
case c < len(table):
|
||||
entry = table[c]
|
||||
case c == len(table) && prev != nil:
|
||||
entry = append([]byte{}, prev...)
|
||||
entry = append(entry, prev[0])
|
||||
default:
|
||||
return out, errors.New("LZW: invalid code")
|
||||
}
|
||||
if err := ensureDecodedSize(len(out), len(entry)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry...)
|
||||
if prev != nil {
|
||||
ne := append([]byte{}, prev...)
|
||||
ne = append(ne, entry[0])
|
||||
table = append(table, ne)
|
||||
// PDF LZW uses "early change": widen one code before full.
|
||||
switch len(table) {
|
||||
case 511, 1023, 2047:
|
||||
codeWidth++
|
||||
case 4096:
|
||||
// Implicit CLEAR expected; some encoders omit it.
|
||||
table = table[:258]
|
||||
codeWidth = 9
|
||||
prev = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
prev = entry
|
||||
}
|
||||
}
|
||||
|
||||
type bitReader struct {
|
||||
data []byte
|
||||
bi int // bit offset into data
|
||||
}
|
||||
|
||||
func (r *bitReader) read(n int) (int, error) {
|
||||
if n <= 0 || n > 32 {
|
||||
return 0, errors.New("bitReader: bad width")
|
||||
}
|
||||
v := 0
|
||||
for i := 0; i < n; i++ {
|
||||
byteIdx := r.bi / 8
|
||||
if byteIdx >= len(r.data) {
|
||||
return 0, errors.New("bitReader: EOF")
|
||||
}
|
||||
bit := (r.data[byteIdx] >> uint(7-(r.bi%8))) & 1
|
||||
v = (v << 1) | int(bit)
|
||||
r.bi++
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Package pdf provides PDF surgery: object enumeration, full stream
|
||||
// filter chain, cross-reference parsing (classical tables and xref
|
||||
// streams), compressed object-stream decoding, and surfacing of hostile
|
||||
// or otherwise-interesting constructs (JavaScript, embedded files, URI
|
||||
// and launch actions, XFA, OpenAction triggers).
|
||||
//
|
||||
// It is not a rendering engine — it will not produce pixels. It is a
|
||||
// structural tool for humans and detection pipelines.
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
)
|
||||
|
||||
const maxPDFParseBytes = 128 << 20
|
||||
|
||||
// Object is one PDF indirect object.
|
||||
type Object struct {
|
||||
Num int64
|
||||
Gen int64
|
||||
Offset int64
|
||||
Dict string
|
||||
HasStream bool
|
||||
StreamOff int64
|
||||
StreamLen int64
|
||||
Filters []string
|
||||
Type string
|
||||
Subtype string
|
||||
Source string // "direct" or "objstm"
|
||||
Container int64 // when Source == "objstm", the ObjStm number
|
||||
}
|
||||
|
||||
// Parse returns a summary of the PDF structure, including cross-
|
||||
// reference entries, an object list (direct + recovered from ObjStms),
|
||||
// surfaced suspicious constructs, and top-level flags.
|
||||
func Parse(d *document.Document) (map[string]any, error) {
|
||||
size := d.Size()
|
||||
head, _ := d.Range(0, 16)
|
||||
if len(head) < 5 || string(head[:5]) != "%PDF-" {
|
||||
return nil, errors.New("not a PDF header")
|
||||
}
|
||||
if size > maxPDFParseBytes {
|
||||
return nil, fmt.Errorf("pdf parse skipped: file > %d MiB", maxPDFParseBytes>>20)
|
||||
}
|
||||
version := string(head[5:8])
|
||||
|
||||
whole, err := d.Range(0, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objs := scanIndirectObjects(whole)
|
||||
|
||||
// Try xref first; fall back gracefully if it isn't parseable.
|
||||
xref, xrefMode, xrefErr := ParseXRef(d, whole)
|
||||
xrefNote := ""
|
||||
if xrefErr != nil {
|
||||
xrefNote = xrefErr.Error()
|
||||
}
|
||||
|
||||
// Decode any ObjStm streams we find and append recovered children.
|
||||
recovered := recoverObjectStreams(whole, d, objs)
|
||||
objs = append(objs, recovered...)
|
||||
|
||||
// Surface flags.
|
||||
surfaced := Surface(objs)
|
||||
encrypted := bytes.Contains(whole, []byte("/Encrypt"))
|
||||
linearised := HasLinearisation(whole)
|
||||
|
||||
// Locate trailer text (for quick eyeball).
|
||||
trailerText := ""
|
||||
if tIdx := bytes.LastIndex(whole, []byte("trailer")); tIdx >= 0 {
|
||||
end := bytes.Index(whole[tIdx:], []byte("startxref"))
|
||||
if end >= 0 {
|
||||
trailerText = string(whole[tIdx : tIdx+end])
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"version": version,
|
||||
"size": size,
|
||||
"encrypted": encrypted,
|
||||
"linearised": linearised,
|
||||
"trailer": trailerText,
|
||||
"objects": objectsToMaps(objs),
|
||||
"xref": xrefEntriesToMaps(xref),
|
||||
"xrefMode": xrefMode,
|
||||
"xrefNote": xrefNote,
|
||||
"suspicious": surfaceToMaps(surfaced),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scanIndirectObjects(whole []byte) []Object {
|
||||
objRE := regexp.MustCompile(`(?m)(\d+)\s+(\d+)\s+obj\b`)
|
||||
locs := objRE.FindAllSubmatchIndex(whole, -1)
|
||||
objs := make([]Object, 0, len(locs))
|
||||
for _, loc := range locs {
|
||||
start := int64(loc[0])
|
||||
num, _ := strconv.ParseInt(string(whole[loc[2]:loc[3]]), 10, 64)
|
||||
gen, _ := strconv.ParseInt(string(whole[loc[4]:loc[5]]), 10, 64)
|
||||
o := Object{Num: num, Gen: gen, Offset: start, Source: "direct"}
|
||||
endIdx := bytes.Index(whole[loc[1]:], []byte("endobj"))
|
||||
if endIdx < 0 {
|
||||
continue
|
||||
}
|
||||
body := whole[loc[1] : loc[1]+endIdx]
|
||||
streamIdx := bytes.Index(body, []byte("stream"))
|
||||
var dict []byte
|
||||
if streamIdx >= 0 {
|
||||
dict = body[:streamIdx]
|
||||
s := streamIdx + len("stream")
|
||||
if s < len(body) && body[s] == '\r' {
|
||||
s++
|
||||
}
|
||||
if s < len(body) && body[s] == '\n' {
|
||||
s++
|
||||
}
|
||||
o.HasStream = true
|
||||
o.StreamOff = int64(loc[1]) + int64(s)
|
||||
endStreamIdx := bytes.LastIndex(body, []byte("endstream"))
|
||||
if endStreamIdx < 0 {
|
||||
continue
|
||||
}
|
||||
endStreamAbs := int64(loc[1]) + int64(endStreamIdx)
|
||||
// trim trailing newline/cr
|
||||
for k := 0; k < 2; k++ {
|
||||
if endStreamAbs > 0 && endStreamAbs-1 < int64(len(whole)) {
|
||||
if whole[endStreamAbs-1] == '\n' || whole[endStreamAbs-1] == '\r' {
|
||||
endStreamAbs--
|
||||
}
|
||||
}
|
||||
}
|
||||
o.StreamLen = endStreamAbs - o.StreamOff
|
||||
} else {
|
||||
dict = body
|
||||
}
|
||||
o.Dict = strings.TrimSpace(string(dict))
|
||||
o.Filters = detectFilters(o.Dict)
|
||||
o.Type = detectType(o.Dict)
|
||||
o.Subtype = detectSubtype(o.Dict)
|
||||
objs = append(objs, o)
|
||||
}
|
||||
return objs
|
||||
}
|
||||
|
||||
func recoverObjectStreams(whole []byte, d *document.Document, objs []Object) []Object {
|
||||
out := []Object{}
|
||||
for _, o := range objs {
|
||||
if !o.HasStream {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(o.Dict, "/Type /ObjStm") && !strings.Contains(o.Dict, "/Type/ObjStm") {
|
||||
continue
|
||||
}
|
||||
raw, err := d.Range(o.StreamOff, o.StreamLen)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Apply filters in order before handing to ObjStm decoder.
|
||||
children, err := DecodeObjStm(raw, o.Dict)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for num, body := range children {
|
||||
out = append(out, Object{
|
||||
Num: num,
|
||||
Gen: 0,
|
||||
Offset: o.StreamOff,
|
||||
Dict: body,
|
||||
HasStream: false,
|
||||
Type: detectType(body),
|
||||
Subtype: detectSubtype(body),
|
||||
Source: "objstm",
|
||||
Container: o.Num,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func objectsToMaps(objs []Object) []map[string]any {
|
||||
out := make([]map[string]any, len(objs))
|
||||
for i, o := range objs {
|
||||
filters := make([]any, len(o.Filters))
|
||||
for j, f := range o.Filters {
|
||||
filters[j] = f
|
||||
}
|
||||
out[i] = map[string]any{
|
||||
"num": o.Num,
|
||||
"gen": o.Gen,
|
||||
"offset": o.Offset,
|
||||
"dict": o.Dict,
|
||||
"hasStream": o.HasStream,
|
||||
"streamOff": o.StreamOff,
|
||||
"streamLen": o.StreamLen,
|
||||
"filters": filters,
|
||||
"type": o.Type,
|
||||
"subtype": o.Subtype,
|
||||
"source": o.Source,
|
||||
"container": o.Container,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func xrefEntriesToMaps(entries []XRefEntry) []map[string]any {
|
||||
out := make([]map[string]any, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = map[string]any{
|
||||
"type": e.Type,
|
||||
"num": e.ObjNum,
|
||||
"gen": e.Gen,
|
||||
"offset": e.Offset,
|
||||
"objStm": e.ObjStmNum,
|
||||
"index": e.Index,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func surfaceToMaps(hits []SuspiciousHit) []map[string]any {
|
||||
out := make([]map[string]any, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = map[string]any{
|
||||
"kind": h.Kind,
|
||||
"num": h.ObjNum,
|
||||
"gen": h.Gen,
|
||||
"offset": h.Offset,
|
||||
"detail": h.Detail,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var filtersRE = regexp.MustCompile(`/Filter\s*(?:\[([^\]]*)\]|/([A-Za-z0-9]+))`)
|
||||
|
||||
func detectFilters(dict string) []string {
|
||||
m := filtersRE.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if m[2] != "" {
|
||||
return []string{m[2]}
|
||||
}
|
||||
parts := strings.Fields(m[1])
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
typeRE = regexp.MustCompile(`/Type\s*/([A-Za-z0-9]+)`)
|
||||
subtypeRE = regexp.MustCompile(`/Subtype\s*/([A-Za-z0-9]+)`)
|
||||
)
|
||||
|
||||
func detectType(dict string) string {
|
||||
m := typeRE.FindStringSubmatch(dict)
|
||||
if len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectSubtype(dict string) string {
|
||||
m := subtypeRE.FindStringSubmatch(dict)
|
||||
if len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DecodeStream reads the stream for object objNum and runs it through
|
||||
// the declared filter chain (with predictor). Works for both direct
|
||||
// objects and those recovered from ObjStm containers.
|
||||
func DecodeStream(d *document.Document, objNum int64) (map[string]any, error) {
|
||||
summary, err := Parse(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range summary["objects"].([]map[string]any) {
|
||||
n, _ := o["num"].(int64)
|
||||
if n != objNum {
|
||||
continue
|
||||
}
|
||||
hasStream, _ := o["hasStream"].(bool)
|
||||
if !hasStream {
|
||||
return nil, fmt.Errorf("object %d has no stream", objNum)
|
||||
}
|
||||
off, _ := o["streamOff"].(int64)
|
||||
ln, _ := o["streamLen"].(int64)
|
||||
raw, err := d.Range(off, ln)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict, _ := o["dict"].(string)
|
||||
filters, _ := o["filters"].([]any)
|
||||
predictor, columns, colors, bits := parseDecodeParms(dict)
|
||||
|
||||
decoded := append([]byte(nil), raw...)
|
||||
applied := []string{}
|
||||
var runErr error
|
||||
for _, f := range filters {
|
||||
name, _ := f.(string)
|
||||
decoded, runErr = ApplyFilter(name, decoded)
|
||||
if runErr != nil {
|
||||
return map[string]any{
|
||||
"objNum": objNum,
|
||||
"raw": raw,
|
||||
"decoded": nil,
|
||||
"error": runErr.Error(),
|
||||
"applied": applied,
|
||||
"filters": filters,
|
||||
}, nil
|
||||
}
|
||||
applied = append(applied, name)
|
||||
}
|
||||
if predictor > 1 {
|
||||
decoded, runErr = ApplyPredictor(decoded, predictor, columns, colors, bits)
|
||||
if runErr != nil {
|
||||
return map[string]any{
|
||||
"objNum": objNum,
|
||||
"raw": raw,
|
||||
"decoded": nil,
|
||||
"error": runErr.Error(),
|
||||
"applied": applied,
|
||||
"filters": filters,
|
||||
}, nil
|
||||
}
|
||||
applied = append(applied, fmt.Sprintf("predictor=%d", predictor))
|
||||
}
|
||||
return map[string]any{
|
||||
"objNum": objNum,
|
||||
"raw": raw,
|
||||
"decoded": decoded,
|
||||
"applied": applied,
|
||||
"filters": filters,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("object %d not found", objNum)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
)
|
||||
|
||||
func TestParseRejectsOversizedPDFBeforeWholeRange(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "huge-*.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() failed: %v", err)
|
||||
}
|
||||
if _, err := file.WriteString("%PDF-1.7\n"); err != nil {
|
||||
t.Fatalf("WriteString() failed: %v", err)
|
||||
}
|
||||
if err := file.Truncate(maxPDFParseBytes + 1); err != nil {
|
||||
t.Fatalf("Truncate() failed: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := document.Open(file.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("document.Open() failed: %v", err)
|
||||
}
|
||||
_, err = Parse(doc)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized PDF to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pdf parse skipped") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SuspiciousHit marks a dictionary fragment worth a closer look. It is
|
||||
// surfaced to the UI with its containing object number so a user can
|
||||
// jump straight to it.
|
||||
type SuspiciousHit struct {
|
||||
Kind string `json:"kind"`
|
||||
ObjNum int64 `json:"objNum"`
|
||||
Gen int64 `json:"gen"`
|
||||
Offset int64 `json:"offset"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Surface walks the enumerated object list and flags anything routinely
|
||||
// abused by hostile PDFs: embedded files, JavaScript, launch actions,
|
||||
// URI actions, form-submission targets, OpenAction / AA triggers.
|
||||
func Surface(objects []Object) []SuspiciousHit {
|
||||
hits := []SuspiciousHit{}
|
||||
for _, o := range objects {
|
||||
dict := o.Dict
|
||||
push := func(kind, detail string) {
|
||||
hits = append(hits, SuspiciousHit{
|
||||
Kind: kind, ObjNum: o.Num, Gen: o.Gen,
|
||||
Offset: o.Offset, Detail: detail,
|
||||
})
|
||||
}
|
||||
// Key markers. Order matters only for readability of the UI.
|
||||
if containsName(dict, "JavaScript") || containsName(dict, "JS") {
|
||||
push("javascript", firstLine(dict, 240))
|
||||
}
|
||||
if containsName(dict, "EmbeddedFile") || containsName(dict, "Filespec") {
|
||||
push("embedded-file", extractName(dict, "/F"))
|
||||
}
|
||||
if containsName(dict, "Launch") {
|
||||
push("launch-action", extractLaunchTarget(dict))
|
||||
}
|
||||
if containsName(dict, "URI") {
|
||||
if uri := extractURI(dict); uri != "" {
|
||||
push("uri-action", uri)
|
||||
}
|
||||
}
|
||||
if containsName(dict, "SubmitForm") {
|
||||
push("submit-form", "")
|
||||
}
|
||||
if containsName(dict, "OpenAction") {
|
||||
push("open-action", "")
|
||||
}
|
||||
if strings.Contains(dict, "/AA") {
|
||||
push("additional-action", "")
|
||||
}
|
||||
if strings.Contains(dict, "/Type /XFA") || strings.Contains(dict, "/XFA ") {
|
||||
push("xfa-form", "")
|
||||
}
|
||||
if strings.Contains(dict, "/RichMedia") {
|
||||
push("rich-media", "")
|
||||
}
|
||||
if strings.Contains(dict, "/Type /Metadata") && strings.Contains(dict, "/Subtype /XML") {
|
||||
push("xmp-metadata", "")
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func containsName(dict, name string) bool {
|
||||
// Match `/Name` as a whole token — not inside /Names/ or /FooJavaScript.
|
||||
pat := regexp.MustCompile(`/` + regexp.QuoteMeta(name) + `\b`)
|
||||
return pat.MatchString(dict)
|
||||
}
|
||||
|
||||
// Go's regexp caps bounded repetitions at 1000 — do not raise this.
|
||||
var uriRE = regexp.MustCompile(`/URI\s*\(([^)]{1,1000})\)`)
|
||||
|
||||
func extractURI(dict string) string {
|
||||
m := uriRE.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
var fnameRE = regexp.MustCompile(`/F\s*\(([^)]{1,512})\)`)
|
||||
|
||||
func extractName(dict, key string) string {
|
||||
_ = key
|
||||
m := fnameRE.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
var launchRE = regexp.MustCompile(`/F\s*\(([^)]{1,512})\)`)
|
||||
|
||||
func extractLaunchTarget(dict string) string {
|
||||
m := launchRE.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func firstLine(s string, max int) string {
|
||||
nl := strings.IndexByte(s, '\n')
|
||||
if nl > 0 {
|
||||
s = s[:nl]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > max {
|
||||
s = s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// HasLinearisation reports whether a PDF is linearised (Fast Web View).
|
||||
// A linearised PDF has an obj dict near the start containing /Linearized.
|
||||
func HasLinearisation(whole []byte) bool {
|
||||
n := 4096
|
||||
if n > len(whole) {
|
||||
n = len(whole)
|
||||
}
|
||||
return bytes.Contains(whole[:n], []byte("/Linearized"))
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
)
|
||||
|
||||
// XRefEntry describes one cross-reference entry after parsing either a
|
||||
// classical xref table or a modern xref stream.
|
||||
//
|
||||
// Type 0 — free object
|
||||
// Type 1 — in-use object; Offset is the byte offset in the file
|
||||
// Type 2 — object inside an object stream; ObjStmNum identifies the
|
||||
// container ObjStm and Index is the 0-based position within it
|
||||
type XRefEntry struct {
|
||||
Type int `json:"type"`
|
||||
ObjNum int64 `json:"objNum"`
|
||||
Gen int64 `json:"gen"`
|
||||
Offset int64 `json:"offset"`
|
||||
ObjStmNum int64 `json:"objStmNum,omitempty"`
|
||||
Index int64 `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
const maxObjectStreamEntries = 1 << 20
|
||||
|
||||
// ParseXRef attempts to reconstruct the cross-reference map. It handles
|
||||
// both classical "xref\n0 N\n..." tables and xref streams (/Type /XRef).
|
||||
// Returns a flat slice of entries covering every object it could resolve.
|
||||
func ParseXRef(d *document.Document, whole []byte) ([]XRefEntry, string, error) {
|
||||
mode := ""
|
||||
entries := map[int64]XRefEntry{}
|
||||
|
||||
// Find "startxref" near the end.
|
||||
idx := bytes.LastIndex(whole, []byte("startxref"))
|
||||
if idx < 0 {
|
||||
return nil, "", errors.New("no startxref")
|
||||
}
|
||||
after := whole[idx+len("startxref"):]
|
||||
after = bytes.TrimLeft(after, " \r\n\t")
|
||||
var xrefPos int64
|
||||
fmt.Sscanf(string(after), "%d", &xrefPos)
|
||||
if xrefPos <= 0 || xrefPos >= int64(len(whole)) {
|
||||
return nil, "", fmt.Errorf("bad startxref offset %d", xrefPos)
|
||||
}
|
||||
|
||||
// Walk the xref chain via /Prev pointers.
|
||||
visited := map[int64]bool{}
|
||||
pos := xrefPos
|
||||
for pos > 0 && !visited[pos] {
|
||||
visited[pos] = true
|
||||
// Classical table if the first non-whitespace token is "xref".
|
||||
tail := whole[pos:]
|
||||
trimmed := bytes.TrimLeft(tail, " \r\n\t")
|
||||
if bytes.HasPrefix(trimmed, []byte("xref")) {
|
||||
if mode == "" {
|
||||
mode = "table"
|
||||
} else if mode != "table" {
|
||||
mode = "hybrid"
|
||||
}
|
||||
nextPrev, err := parseClassicalXRef(trimmed, entries)
|
||||
if err != nil {
|
||||
return mapToSlice(entries), mode, err
|
||||
}
|
||||
pos = nextPrev
|
||||
continue
|
||||
}
|
||||
// Otherwise an xref stream: an indirect object at `pos`.
|
||||
if mode == "" {
|
||||
mode = "stream"
|
||||
} else if mode != "stream" {
|
||||
mode = "hybrid"
|
||||
}
|
||||
nextPrev, err := parseXRefStream(d, whole, pos, entries)
|
||||
if err != nil {
|
||||
return mapToSlice(entries), mode, err
|
||||
}
|
||||
pos = nextPrev
|
||||
}
|
||||
return mapToSlice(entries), mode, nil
|
||||
}
|
||||
|
||||
func mapToSlice(m map[int64]XRefEntry) []XRefEntry {
|
||||
out := make([]XRefEntry, 0, len(m))
|
||||
for _, e := range m {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var xrefRangeRE = regexp.MustCompile(`(?m)^\s*(\d+)\s+(\d+)\s*$`)
|
||||
var xrefRowRE = regexp.MustCompile(`^(\d{10})\s+(\d{5})\s+([nf])`)
|
||||
var prevRE = regexp.MustCompile(`/Prev\s+(\d+)`)
|
||||
|
||||
func parseClassicalXRef(block []byte, entries map[int64]XRefEntry) (int64, error) {
|
||||
// Skip "xref" line.
|
||||
nl := bytes.IndexByte(block, '\n')
|
||||
if nl < 0 {
|
||||
return 0, errors.New("xref: no newline after keyword")
|
||||
}
|
||||
cursor := block[nl+1:]
|
||||
for {
|
||||
m := xrefRangeRE.FindSubmatchIndex(cursor)
|
||||
if m == nil {
|
||||
break
|
||||
}
|
||||
if m[0] > 0 {
|
||||
// Subsection must begin at the start of the cursor.
|
||||
break
|
||||
}
|
||||
start, _ := strconv.ParseInt(string(cursor[m[2]:m[3]]), 10, 64)
|
||||
count, _ := strconv.ParseInt(string(cursor[m[4]:m[5]]), 10, 64)
|
||||
// Advance past the subsection line.
|
||||
nl2 := bytes.IndexByte(cursor, '\n')
|
||||
if nl2 < 0 {
|
||||
break
|
||||
}
|
||||
cursor = cursor[nl2+1:]
|
||||
for i := int64(0); i < count; i++ {
|
||||
if len(cursor) < 20 {
|
||||
return 0, errors.New("xref: truncated subsection")
|
||||
}
|
||||
row := cursor[:20]
|
||||
rm := xrefRowRE.FindSubmatch(row)
|
||||
if rm != nil {
|
||||
off, _ := strconv.ParseInt(string(rm[1]), 10, 64)
|
||||
gen, _ := strconv.ParseInt(string(rm[2]), 10, 64)
|
||||
typ := 1
|
||||
if rm[3][0] == 'f' {
|
||||
typ = 0
|
||||
}
|
||||
num := start + i
|
||||
if _, seen := entries[num]; !seen {
|
||||
entries[num] = XRefEntry{
|
||||
Type: typ, ObjNum: num, Gen: gen, Offset: off,
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = cursor[20:]
|
||||
}
|
||||
}
|
||||
// Locate trailer and /Prev.
|
||||
tIdx := bytes.Index(block, []byte("trailer"))
|
||||
if tIdx < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
trailer := block[tIdx:]
|
||||
if e := bytes.Index(trailer, []byte("startxref")); e > 0 {
|
||||
trailer = trailer[:e]
|
||||
}
|
||||
if pm := prevRE.FindSubmatch(trailer); pm != nil {
|
||||
prev, _ := strconv.ParseInt(string(pm[1]), 10, 64)
|
||||
return prev, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func parseXRefStream(d *document.Document, whole []byte, pos int64, entries map[int64]XRefEntry) (int64, error) {
|
||||
// Locate "obj" after pos, then "stream"/"endstream".
|
||||
if pos < 0 || int(pos) >= len(whole) {
|
||||
return 0, errors.New("xref stream: bad offset")
|
||||
}
|
||||
body := whole[pos:]
|
||||
objIdx := bytes.Index(body, []byte("obj"))
|
||||
if objIdx < 0 {
|
||||
return 0, errors.New("xref stream: no obj")
|
||||
}
|
||||
dictEnd := bytes.Index(body[objIdx:], []byte("stream"))
|
||||
if dictEnd < 0 {
|
||||
return 0, errors.New("xref stream: no stream")
|
||||
}
|
||||
dict := string(body[objIdx : objIdx+dictEnd])
|
||||
streamStart := objIdx + dictEnd + len("stream")
|
||||
if streamStart < len(body) && body[streamStart] == '\r' {
|
||||
streamStart++
|
||||
}
|
||||
if streamStart < len(body) && body[streamStart] == '\n' {
|
||||
streamStart++
|
||||
}
|
||||
endIdx := bytes.Index(body[streamStart:], []byte("endstream"))
|
||||
if endIdx < 0 {
|
||||
return 0, errors.New("xref stream: no endstream")
|
||||
}
|
||||
raw := body[streamStart : streamStart+endIdx]
|
||||
// trim trailing newline.
|
||||
if len(raw) > 0 && raw[len(raw)-1] == '\n' {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
if len(raw) > 0 && raw[len(raw)-1] == '\r' {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
|
||||
// Decode the xref-stream dictionary fields.
|
||||
filters := parseDictNameList(dict, "/Filter")
|
||||
w := parseDictIntArray(dict, "/W")
|
||||
index := parseDictIntArray(dict, "/Index")
|
||||
sizeN := parseDictInt(dict, "/Size")
|
||||
predictor, columns, colors, bits := parseDecodeParms(dict)
|
||||
|
||||
// Apply filters.
|
||||
data := append([]byte(nil), raw...)
|
||||
var err error
|
||||
for _, f := range filters {
|
||||
data, err = ApplyFilter(f, data)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("xref stream: filter %s: %w", f, err)
|
||||
}
|
||||
}
|
||||
if predictor > 1 {
|
||||
data, err = ApplyPredictor(data, predictor, columns, colors, bits)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if len(w) != 3 {
|
||||
return 0, fmt.Errorf("xref stream: bad /W array %v", w)
|
||||
}
|
||||
entryLen := 0
|
||||
for _, width := range w {
|
||||
if width < 0 || width > 8 {
|
||||
return 0, fmt.Errorf("xref stream: invalid field width %d", width)
|
||||
}
|
||||
entryLen += width
|
||||
}
|
||||
if entryLen <= 0 {
|
||||
return 0, errors.New("xref stream: zero-length entries")
|
||||
}
|
||||
if len(index) == 0 {
|
||||
if sizeN < 0 {
|
||||
return 0, errors.New("xref stream: negative /Size")
|
||||
}
|
||||
index = []int{0, sizeN}
|
||||
}
|
||||
if len(index)%2 != 0 {
|
||||
return 0, errors.New("xref stream: /Index must contain start/count pairs")
|
||||
}
|
||||
// Walk subsections.
|
||||
pi := 0
|
||||
for s := 0; s+1 < len(index); s += 2 {
|
||||
first := int64(index[s])
|
||||
count := int64(index[s+1])
|
||||
if first < 0 || count < 0 {
|
||||
return 0, errors.New("xref stream: negative /Index value")
|
||||
}
|
||||
for i := int64(0); i < count; i++ {
|
||||
if pi+entryLen > len(data) {
|
||||
break
|
||||
}
|
||||
row := data[pi : pi+entryLen]
|
||||
pi += entryLen
|
||||
t := readBE(row[:w[0]])
|
||||
f2 := readBE(row[w[0] : w[0]+w[1]])
|
||||
f3 := readBE(row[w[0]+w[1] : w[0]+w[1]+w[2]])
|
||||
if w[0] == 0 {
|
||||
t = 1 // default Type 1 per spec
|
||||
}
|
||||
num := first + i
|
||||
if _, seen := entries[num]; seen {
|
||||
continue
|
||||
}
|
||||
switch t {
|
||||
case 0:
|
||||
entries[num] = XRefEntry{Type: 0, ObjNum: num, Gen: f3}
|
||||
case 1:
|
||||
entries[num] = XRefEntry{Type: 1, ObjNum: num, Gen: f3, Offset: f2}
|
||||
case 2:
|
||||
entries[num] = XRefEntry{
|
||||
Type: 2, ObjNum: num, ObjStmNum: f2, Index: f3,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if prev := parseDictInt(dict, "/Prev"); prev > 0 {
|
||||
return int64(prev), nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func readBE(b []byte) int64 {
|
||||
v := int64(0)
|
||||
for _, c := range b {
|
||||
v = (v << 8) | int64(c)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ObjStm — compressed object streams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DecodeObjStm decodes an /ObjStm stream and returns a map of
|
||||
// object-number → raw object body (text, never with "stream").
|
||||
func DecodeObjStm(raw []byte, dict string) (map[int64]string, error) {
|
||||
filters := parseDictNameList(dict, "/Filter")
|
||||
predictor, columns, colors, bits := parseDecodeParms(dict)
|
||||
data := append([]byte(nil), raw...)
|
||||
var err error
|
||||
for _, f := range filters {
|
||||
data, err = ApplyFilter(f, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if predictor > 1 {
|
||||
data, err = ApplyPredictor(data, predictor, columns, colors, bits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
n := parseDictInt(dict, "/N")
|
||||
first := parseDictInt(dict, "/First")
|
||||
if n <= 0 || first <= 0 || first >= len(data) {
|
||||
return nil, fmt.Errorf("ObjStm: bad /N=%d /First=%d", n, first)
|
||||
}
|
||||
header := strings.Fields(string(data[:first]))
|
||||
if n > maxObjectStreamEntries || n > len(header)/2 {
|
||||
return nil, fmt.Errorf("ObjStm: header has %d tokens, need %d", len(header), n*2)
|
||||
}
|
||||
out := map[int64]string{}
|
||||
offsets := make([]int, n)
|
||||
nums := make([]int64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, _ := strconv.ParseInt(header[i*2], 10, 64)
|
||||
off, _ := strconv.Atoi(header[i*2+1])
|
||||
nums[i] = num
|
||||
offsets[i] = off
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
start := first + offsets[i]
|
||||
end := len(data)
|
||||
if i+1 < n {
|
||||
end = first + offsets[i+1]
|
||||
}
|
||||
if start < 0 || end > len(data) || start > end {
|
||||
continue
|
||||
}
|
||||
out[nums[i]] = strings.TrimSpace(string(data[start:end]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small dictionary helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var decodeParmRE = regexp.MustCompile(`/DecodeParms\s*<<([^>]*)>>`)
|
||||
|
||||
func parseDictInt(dict, key string) int {
|
||||
pat := regexp.MustCompile(regexp.QuoteMeta(key) + `\s+(-?\d+)\b`)
|
||||
m := pat.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
func parseDictIntArray(dict, key string) []int {
|
||||
pat := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*\[([^\]]*)\]`)
|
||||
m := pat.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Fields(m[1])
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDictNameList(dict, key string) []string {
|
||||
// Handles both `/Filter /FlateDecode` and `/Filter [/A85 /Flate]`.
|
||||
pat1 := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*/([A-Za-z0-9]+)`)
|
||||
if m := pat1.FindStringSubmatch(dict); m != nil {
|
||||
return []string{m[1]}
|
||||
}
|
||||
pat2 := regexp.MustCompile(regexp.QuoteMeta(key) + `\s*\[([^\]]*)\]`)
|
||||
if m := pat2.FindStringSubmatch(dict); m != nil {
|
||||
parts := strings.Fields(m[1])
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, strings.TrimPrefix(p, "/"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDecodeParms(dict string) (predictor, columns, colors, bits int) {
|
||||
m := decodeParmRE.FindStringSubmatch(dict)
|
||||
if m == nil {
|
||||
return 1, 1, 1, 8
|
||||
}
|
||||
inner := m[1]
|
||||
predictor = parseDictInt(inner, "/Predictor")
|
||||
if predictor == 0 {
|
||||
predictor = 1
|
||||
}
|
||||
columns = parseDictInt(inner, "/Columns")
|
||||
if columns == 0 {
|
||||
columns = 1
|
||||
}
|
||||
colors = parseDictInt(inner, "/Colors")
|
||||
if colors == 0 {
|
||||
colors = 1
|
||||
}
|
||||
bits = parseDictInt(inner, "/BitsPerComponent")
|
||||
if bits == 0 {
|
||||
bits = 8
|
||||
}
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user