v3 candidate
This commit is contained in:
@@ -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