diff --git a/CHANGELOG.md b/CHANGELOG.md index a8aa0c8..e38d20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **In-Memory PDF Opening** — `OpenFromBytes()`, `OpenFromBytesWithPassword()`, and context-aware variants for server-side workflows without temp file I/O (#68) - **Embedded Font Extraction** — `Document.GetEmbeddedFonts()` and `GetEmbeddedFontsForPage()` extract TTF/OTF font binaries from parsed PDFs for round-trip preservation (#67) - **LoadTTFFromBytes** — `fonts.LoadTTFFromBytes()` constructor for loading fonts from extracted binary data directly into Creator API +- **Vector Graphics Extraction** — `Document.GetVectorGraphics()` and `GetVectorGraphicsForPage()` extract paths, bezier curves, colors, and opacity from parsed PDFs (#66) + - Path verb + coordinates model (MoveTo, LineTo, CubicTo, QuadTo, Close) compatible with gogpu/gg + - Graphics state stack (`q`/`Q`), CTM tracking (`cm`), CMYK→RGB conversion + - Stroke, fill, and fill-stroke paint modes with separate colors and opacity + - Line cap, line join, miter limit extraction ### Changed - **Parser Reader Refactor** — Internal `Reader` refactored from `*os.File` to `io.ReadSeeker` interface, enabling in-memory PDF support diff --git a/README.md b/README.md index 3cc5b5f..32d4ad1 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,20 @@ for _, f := range fonts { } ``` +### Extracting Vector Graphics (NEW) + +```go +doc, _ := gxpdf.Open("diagram.pdf") +defer doc.Close() + +// Extract all vector paths with colors, opacity, and CTM-transformed coordinates +paths, _ := doc.GetVectorGraphicsForPage(1) +for _, p := range paths { + fmt.Printf("Path: %d verbs, mode=%v, stroke=%v, fill=%v\n", + len(p.Verbs), p.PaintMode, p.StrokeColor, p.FillColor) +} +``` + ### Extracting Tables from PDFs ```go diff --git a/internal/extractor/vector_parser.go b/internal/extractor/vector_parser.go new file mode 100644 index 0000000..a2e4e1d --- /dev/null +++ b/internal/extractor/vector_parser.go @@ -0,0 +1,732 @@ +package extractor + +import ( + "fmt" + "math" + + "github.com/coregx/gxpdf/internal/parser" +) + +// vectorGraphicsState is the full mutable PDF graphics state tracked during +// vector path extraction. +// +// This state is pushed/popped by the q/Q operators and updated by cm, w, J, +// j, M, RG, rg, G, g, K, k, and gs operators. +// +// Reference: PDF 1.7 specification, Section 8.4 (Graphics State). +type vectorGraphicsState struct { + // CTM is the Current Transformation Matrix in page space. + // Initialized to identity; updated by the cm operator. + // Format: [a b c d e f] as in PDF spec Section 8.3.3. + CTM Matrix + + // StrokeColor is the current stroke color in RGBA (0–1, alpha always 1.0 + // unless modified by ExtGState /CA). + StrokeColor [4]float64 + + // FillColor is the current fill color in RGBA (0–1, alpha always 1.0 + // unless modified by ExtGState /ca). + FillColor [4]float64 + + // LineWidth is the current line width in points. + LineWidth float64 + + // LineCap is the PDF line cap style (0=butt, 1=round, 2=square). + LineCap int + + // LineJoin is the PDF line join style (0=miter, 1=round, 2=bevel). + LineJoin int + + // MiterLimit is the miter join limit. + MiterLimit float64 + + // StrokeOpacity is the stroke opacity from ExtGState /CA (0–1). + StrokeOpacity float64 + + // FillOpacity is the fill opacity from ExtGState /ca (0–1). + FillOpacity float64 +} + +// newVectorGraphicsState returns a graphics state with PDF defaults. +// +// Reference: PDF 1.7 specification, Table 51 (Device-Dependent Graphics State +// Parameters) and Table 53 (Device-Independent Graphics State Parameters). +func newVectorGraphicsState() vectorGraphicsState { + return vectorGraphicsState{ + CTM: Identity(), + StrokeColor: [4]float64{0, 0, 0, 1}, // black + FillColor: [4]float64{0, 0, 0, 1}, // black + LineWidth: 1.0, + LineCap: 0, + LineJoin: 0, + MiterLimit: 10.0, + StrokeOpacity: 1.0, + FillOpacity: 1.0, + } +} + +// vectorStateStack implements the graphics state save/restore mechanism +// driven by the q (save) and Q (restore) PDF operators. +// +// Reference: PDF 1.7 specification, Section 8.4.4 (Graphics State Operators). +type vectorStateStack struct { + states []vectorGraphicsState +} + +// save pushes a copy of gs onto the stack. +func (s *vectorStateStack) save(gs vectorGraphicsState) { + s.states = append(s.states, gs) +} + +// restore pops the top of the stack. +// +// Returns false and leaves gs unchanged when the stack is empty (malformed PDF +// guard — Q without matching q). +func (s *vectorStateStack) restore() (vectorGraphicsState, bool) { + if len(s.states) == 0 { + return vectorGraphicsState{}, false + } + top := s.states[len(s.states)-1] + s.states = s.states[:len(s.states)-1] + return top, true +} + +// pathSegment stores a single segment of the path being built. +type pathSegment struct { + verb PathVerb + coords []float64 // already transformed to page space +} + +// VectorParser extracts vector graphics paths from PDF content streams. +// +// It is separate from GraphicsParser (which serves table-line detection) +// and adds full CTM tracking, graphics-state stack, curve operators, +// fill capture, opacity, and CMYK conversion. +// +// Usage: +// +// vp := NewVectorParser(reader) +// paths, err := vp.ParseFromPage(0) +type VectorParser struct { + reader *parser.Reader + state vectorGraphicsState + stack vectorStateStack + curPath []pathSegment // path being assembled between m..h and S/f/B + curStart Point // subpath start (for h/close) + curPoint Point // current pen position + hasCur bool // whether curPoint is valid + paths []*VectorPath + pageNum int + pageRes *parser.Dictionary // current page Resources dict (for gs lookup) +} + +// NewVectorParser creates a VectorParser for the given PDF reader. +func NewVectorParser(reader *parser.Reader) *VectorParser { + return &VectorParser{ + reader: reader, + state: newVectorGraphicsState(), + } +} + +// ParseFromPage extracts all vector paths from the specified page. +// +// Page numbers are 0-based (first page is 0). +// +// Returns a slice of VectorPath values, or an error if extraction fails. +// An empty page returns an empty (non-nil) slice. +func (vp *VectorParser) ParseFromPage(pageNum int) ([]*VectorPath, error) { + // Reset per-page state. + vp.state = newVectorGraphicsState() + vp.stack = vectorStateStack{} + vp.curPath = nil + vp.hasCur = false + vp.paths = nil + vp.pageNum = pageNum + + page, err := vp.reader.GetPage(pageNum) + if err != nil { + return nil, fmt.Errorf("failed to get page %d: %w", pageNum, err) + } + + // Load page resources so gs operator can look up ExtGState entries. + vp.pageRes = vp.getPageResources(page) + + contentData, err := vp.getPageContent(page) + if err != nil { + return nil, fmt.Errorf("failed to get page content: %w", err) + } + + if len(contentData) == 0 { + return []*VectorPath{}, nil + } + + contentParser := NewContentParser(contentData) + operators, err := contentParser.ParseOperators() + if err != nil { + return nil, fmt.Errorf("failed to parse content stream: %w", err) + } + + for _, op := range operators { + vp.processVectorOperator(op) + } + + return vp.paths, nil +} + +// getPageResources retrieves the Resources dictionary from a page dictionary. +// +// Resources can be inherited from parent page-tree nodes, but for the +// ExtGState lookup we only need to inspect the page's own resources. +// Inherited resources would require a recursive tree walk which we +// deliberately omit to keep Phase 1 simple. +func (vp *VectorParser) getPageResources(page *parser.Dictionary) *parser.Dictionary { + resObj := page.Get("Resources") + if resObj == nil { + return parser.NewDictionary() + } + if ref, ok := resObj.(*parser.IndirectReference); ok { + resolved, err := vp.reader.GetObject(ref.Number) + if err == nil { + if d, ok := resolved.(*parser.Dictionary); ok { + return d + } + } + } + if d, ok := resObj.(*parser.Dictionary); ok { + return d + } + return parser.NewDictionary() +} + +// getPageContent retrieves and concatenates content stream bytes for a page. +// +//nolint:cyclop,dupl // Similar to GraphicsParser.getPageContent, refactoring later. +func (vp *VectorParser) getPageContent(page *parser.Dictionary) ([]byte, error) { + contentsObj := page.Get("Contents") + if contentsObj == nil { + return []byte{}, nil + } + + if ref, ok := contentsObj.(*parser.IndirectReference); ok { + resolved, err := vp.reader.GetObject(ref.Number) + if err != nil { + return nil, fmt.Errorf("failed to resolve contents reference: %w", err) + } + contentsObj = resolved + } + + switch obj := contentsObj.(type) { + case *parser.Stream: + return vp.decodeStream(obj) + + case *parser.Array: + var all []byte + for i := 0; i < obj.Len(); i++ { + streamRef := obj.Get(i) + if streamRef == nil { + continue + } + if ref, ok := streamRef.(*parser.IndirectReference); ok { + resolved, err := vp.reader.GetObject(ref.Number) + if err != nil { + continue + } + streamRef = resolved + } + if stream, ok := streamRef.(*parser.Stream); ok { + content, err := vp.decodeStream(stream) + if err != nil { + continue + } + all = append(all, content...) + all = append(all, ' ') + } + } + return all, nil + + default: + return nil, fmt.Errorf("unexpected Contents type: %T", obj) + } +} + +// decodeStream decodes a PDF content stream using its filter chain. +// +//nolint:dupl // Similar to GraphicsParser.decodeStream, refactoring later. +func (vp *VectorParser) decodeStream(stream *parser.Stream) ([]byte, error) { + filterObj := stream.Dictionary().Get("Filter") + if filterObj == nil { + return stream.Content(), nil + } + + var filterName string + if name, ok := filterObj.(*parser.Name); ok { + filterName = name.Value() + } else if arr, ok := filterObj.(*parser.Array); ok { + if arr.Len() > 0 { + if name, ok := arr.Get(0).(*parser.Name); ok { + filterName = name.Value() + } + } + } + + switch filterName { + case filterFlateDecode: + te := &TextExtractor{reader: vp.reader} + return te.decodeFlateDecode(stream.Content()) + case "": + return stream.Content(), nil + default: + return stream.Content(), nil + } +} + +// processVectorOperator dispatches a single content-stream operator. +// +//nolint:cyclop,funlen // Operator dispatch requires many cases by design. +func (vp *VectorParser) processVectorOperator(op *Operator) { + switch op.Name { + + // ── Graphics state: save / restore / CTM ────────────────────────────── + + case "q": // Save graphics state → push stack. + vp.stack.save(vp.state) + + case "Q": // Restore graphics state → pop stack. + if gs, ok := vp.stack.restore(); ok { + vp.state = gs + } + + case "cm": // Modify CTM: CTM = operand × CTM. + if len(op.Operands) >= 6 { + a := getNumber(op.Operands[0]) + b := getNumber(op.Operands[1]) + c := getNumber(op.Operands[2]) + d := getNumber(op.Operands[3]) + e := getNumber(op.Operands[4]) + f := getNumber(op.Operands[5]) + if a != nil && b != nil && c != nil && d != nil && e != nil && f != nil { + operand := NewMatrix(*a, *b, *c, *d, *e, *f) + // PDF spec: new CTM = operand × old CTM. + vp.state.CTM = operand.Multiply(vp.state.CTM) + } + } + + // ── Graphics state parameters ────────────────────────────────────────── + + case "w": // Set line width. + if len(op.Operands) >= 1 { + if v := getNumber(op.Operands[0]); v != nil { + vp.state.LineWidth = *v + } + } + + case "J": // Set line cap style (0, 1, or 2). + if len(op.Operands) >= 1 { + if v := getNumber(op.Operands[0]); v != nil { + vp.state.LineCap = int(*v) + } + } + + case "j": // Set line join style (0, 1, or 2). + if len(op.Operands) >= 1 { + if v := getNumber(op.Operands[0]); v != nil { + vp.state.LineJoin = int(*v) + } + } + + case "M": // Set miter limit. + if len(op.Operands) >= 1 { + if v := getNumber(op.Operands[0]); v != nil { + vp.state.MiterLimit = *v + } + } + + case "d": // Set dash pattern — stored implicitly; we don't expose it yet. + // No-op for Phase 1; the operator is consumed to keep state consistent. + + case "gs": // Apply named ExtGState. + if len(op.Operands) >= 1 { + if name, ok := op.Operands[0].(*parser.Name); ok { + vp.applyExtGState(name.Value()) + } + } + + // ── Color operators ──────────────────────────────────────────────────── + + case "RG": // Set RGB stroke color. + if len(op.Operands) >= 3 { + r, g, b := getNumber(op.Operands[0]), getNumber(op.Operands[1]), getNumber(op.Operands[2]) + if r != nil && g != nil && b != nil { + vp.state.StrokeColor = [4]float64{clamp01(*r), clamp01(*g), clamp01(*b), vp.state.StrokeColor[3]} + } + } + + case "rg": // Set RGB fill color. + if len(op.Operands) >= 3 { + r, g, b := getNumber(op.Operands[0]), getNumber(op.Operands[1]), getNumber(op.Operands[2]) + if r != nil && g != nil && b != nil { + vp.state.FillColor = [4]float64{clamp01(*r), clamp01(*g), clamp01(*b), vp.state.FillColor[3]} + } + } + + case "G": // Set grayscale stroke color. + if len(op.Operands) >= 1 { + if gray := getNumber(op.Operands[0]); gray != nil { + g := clamp01(*gray) + vp.state.StrokeColor = [4]float64{g, g, g, vp.state.StrokeColor[3]} + } + } + + case "g": // Set grayscale fill color. + if len(op.Operands) >= 1 { + if gray := getNumber(op.Operands[0]); gray != nil { + g := clamp01(*gray) + vp.state.FillColor = [4]float64{g, g, g, vp.state.FillColor[3]} + } + } + + case "K": // Set CMYK stroke color. + if len(op.Operands) >= 4 { + c, m, y, k := getNumber(op.Operands[0]), getNumber(op.Operands[1]), + getNumber(op.Operands[2]), getNumber(op.Operands[3]) + if c != nil && m != nil && y != nil && k != nil { + r, g, b := cmykToRGB(*c, *m, *y, *k) + vp.state.StrokeColor = [4]float64{r, g, b, vp.state.StrokeColor[3]} + } + } + + case "k": // Set CMYK fill color. + if len(op.Operands) >= 4 { + c, m, y, k := getNumber(op.Operands[0]), getNumber(op.Operands[1]), + getNumber(op.Operands[2]), getNumber(op.Operands[3]) + if c != nil && m != nil && y != nil && k != nil { + r, g, b := cmykToRGB(*c, *m, *y, *k) + vp.state.FillColor = [4]float64{r, g, b, vp.state.FillColor[3]} + } + } + + // ── Path construction ────────────────────────────────────────────────── + + case "m": // moveto. + if len(op.Operands) >= 2 { + x, y := getNumber(op.Operands[0]), getNumber(op.Operands[1]) + if x != nil && y != nil { + ux, uy := vp.state.CTM.Transform(*x, *y) + vp.curPath = append(vp.curPath, pathSegment{VerbMoveTo, []float64{ux, uy}}) + vp.curPoint = NewPoint(ux, uy) + vp.curStart = vp.curPoint + vp.hasCur = true + } + } + + case "l": // lineto. + if len(op.Operands) >= 2 { + x, y := getNumber(op.Operands[0]), getNumber(op.Operands[1]) + if x != nil && y != nil { + ux, uy := vp.state.CTM.Transform(*x, *y) + vp.curPath = append(vp.curPath, pathSegment{VerbLineTo, []float64{ux, uy}}) + vp.curPoint = NewPoint(ux, uy) + vp.hasCur = true + } + } + + case "c": // cubic Bézier: c1x c1y c2x c2y x y. + if len(op.Operands) >= 6 { + vp.appendCubic( + op.Operands[0], op.Operands[1], + op.Operands[2], op.Operands[3], + op.Operands[4], op.Operands[5], + ) + } + + case "v": // cubic Bézier shorthand: c1 = current point. + // Operands: c2x c2y x y. + if len(op.Operands) >= 4 && vp.hasCur { + // c1 is the current point (already in page space). + c1x, c1y := vp.curPoint.X, vp.curPoint.Y + c2x, c2y := getNumber(op.Operands[0]), getNumber(op.Operands[1]) + ex, ey := getNumber(op.Operands[2]), getNumber(op.Operands[3]) + if c2x != nil && c2y != nil && ex != nil && ey != nil { + tc2x, tc2y := vp.state.CTM.Transform(*c2x, *c2y) + tex, tey := vp.state.CTM.Transform(*ex, *ey) + vp.curPath = append(vp.curPath, pathSegment{ + VerbCubicTo, + []float64{c1x, c1y, tc2x, tc2y, tex, tey}, + }) + vp.curPoint = NewPoint(tex, tey) + vp.hasCur = true + } + } + + case "y": // cubic Bézier shorthand: c2 = endpoint. + // Operands: c1x c1y x y. + if len(op.Operands) >= 4 { + c1x, c1y := getNumber(op.Operands[0]), getNumber(op.Operands[1]) + ex, ey := getNumber(op.Operands[2]), getNumber(op.Operands[3]) + if c1x != nil && c1y != nil && ex != nil && ey != nil { + tc1x, tc1y := vp.state.CTM.Transform(*c1x, *c1y) + tex, tey := vp.state.CTM.Transform(*ex, *ey) + // c2 = endpoint (already in page space). + vp.curPath = append(vp.curPath, pathSegment{ + VerbCubicTo, + []float64{tc1x, tc1y, tex, tey, tex, tey}, + }) + vp.curPoint = NewPoint(tex, tey) + vp.hasCur = true + } + } + + case "re": // rectangle: x y w h. + if len(op.Operands) >= 4 { + vp.appendRectangle( + op.Operands[0], op.Operands[1], + op.Operands[2], op.Operands[3], + ) + } + + case "h": // close subpath. + if vp.hasCur { + vp.curPath = append(vp.curPath, pathSegment{VerbClose, nil}) + vp.curPoint = vp.curStart + } + + // ── Path painting ────────────────────────────────────────────────────── + + case "S": // Stroke. + vp.emitPath(PaintStroke) + + case "s": // Close + stroke. + if vp.hasCur { + vp.curPath = append(vp.curPath, pathSegment{VerbClose, nil}) + } + vp.emitPath(PaintStroke) + + case "f", "F": // Fill (non-zero winding). + vp.emitPath(PaintFill) + + case "f*": // Fill (even-odd). + vp.emitPath(PaintFill) + + case "B": // Fill + stroke (non-zero winding). + vp.emitPath(PaintFillStroke) + + case "B*": // Fill + stroke (even-odd). + vp.emitPath(PaintFillStroke) + + case "b": // Close + fill + stroke (non-zero). + if vp.hasCur { + vp.curPath = append(vp.curPath, pathSegment{VerbClose, nil}) + } + vp.emitPath(PaintFillStroke) + + case "b*": // Close + fill + stroke (even-odd). + if vp.hasCur { + vp.curPath = append(vp.curPath, pathSegment{VerbClose, nil}) + } + vp.emitPath(PaintFillStroke) + + case "n": // End path without painting (clip-only, discard). + vp.clearCurPath() + + // ── Clipping (consume to maintain correct state) ─────────────────────── + + case "W", "W*": // Set clip path — we discard the path but handle the operator. + vp.clearCurPath() + } +} + +// appendCubic transforms and records a full cubic Bézier segment. +func (vp *VectorParser) appendCubic(c1xObj, c1yObj, c2xObj, c2yObj, exObj, eyObj parser.PdfObject) { + c1x, c1y := getNumber(c1xObj), getNumber(c1yObj) + c2x, c2y := getNumber(c2xObj), getNumber(c2yObj) + ex, ey := getNumber(exObj), getNumber(eyObj) + if c1x == nil || c1y == nil || c2x == nil || c2y == nil || ex == nil || ey == nil { + return + } + tc1x, tc1y := vp.state.CTM.Transform(*c1x, *c1y) + tc2x, tc2y := vp.state.CTM.Transform(*c2x, *c2y) + tex, tey := vp.state.CTM.Transform(*ex, *ey) + vp.curPath = append(vp.curPath, pathSegment{ + VerbCubicTo, + []float64{tc1x, tc1y, tc2x, tc2y, tex, tey}, + }) + vp.curPoint = NewPoint(tex, tey) + vp.hasCur = true +} + +// appendRectangle expands a rectangle into path segments. +// +// PDF re operator: x y w h → produces the equivalent of: +// +// m x y l x+w y l x+w y+h l x y+h h +// +// All corner points are transformed through the CTM. +func (vp *VectorParser) appendRectangle(xObj, yObj, wObj, hObj parser.PdfObject) { + x, y := getNumber(xObj), getNumber(yObj) + w, h := getNumber(wObj), getNumber(hObj) + if x == nil || y == nil || w == nil || h == nil { + return + } + + // Four corners in user (pre-CTM) space, then transformed. + x0, y0 := *x, *y + x1, y1 := *x+*w, *y + x2, y2 := *x+*w, *y+*h + x3, y3 := *x, *y+*h + + tx0, ty0 := vp.state.CTM.Transform(x0, y0) + tx1, ty1 := vp.state.CTM.Transform(x1, y1) + tx2, ty2 := vp.state.CTM.Transform(x2, y2) + tx3, ty3 := vp.state.CTM.Transform(x3, y3) + + startPt := NewPoint(tx0, ty0) + + vp.curPath = append(vp.curPath, + pathSegment{VerbMoveTo, []float64{tx0, ty0}}, + pathSegment{VerbLineTo, []float64{tx1, ty1}}, + pathSegment{VerbLineTo, []float64{tx2, ty2}}, + pathSegment{VerbLineTo, []float64{tx3, ty3}}, + pathSegment{VerbClose, nil}, + ) + vp.curPoint = startPt + vp.curStart = startPt + vp.hasCur = true +} + +// emitPath converts the accumulated curPath into a VectorPath and resets state. +func (vp *VectorParser) emitPath(mode PaintMode) { + if len(vp.curPath) == 0 { + return + } + + path := &VectorPath{ + PageNum: vp.pageNum, + LineWidth: vp.state.LineWidth, + LineCap: vp.state.LineCap, + LineJoin: vp.state.LineJoin, + MiterLimit: vp.state.MiterLimit, + PaintMode: mode, + StrokeColor: vp.state.StrokeColor, + FillColor: vp.state.FillColor, + } + + // Apply opacity: use the minimum of stroke and fill opacities so that + // a FillStroke path carries the most restrictive value. + opacity := vp.state.StrokeOpacity + if vp.state.FillOpacity < opacity { + opacity = vp.state.FillOpacity + } + path.Opacity = opacity + + // Set alpha channel on the exported colors. + path.StrokeColor[3] = vp.state.StrokeOpacity + path.FillColor[3] = vp.state.FillOpacity + + // Flatten verbs and coords. + for _, seg := range vp.curPath { + path.Verbs = append(path.Verbs, seg.verb) + path.Coords = append(path.Coords, seg.coords...) + } + + vp.paths = append(vp.paths, path) + vp.clearCurPath() +} + +// clearCurPath resets the current path construction state. +func (vp *VectorParser) clearCurPath() { + vp.curPath = nil + vp.hasCur = false +} + +// applyExtGState looks up a named graphics state resource and applies +// the /ca (fill opacity) and /CA (stroke opacity) entries to the current state. +// +// Only these two keys are relevant for Phase 1. All other ExtGState parameters +// (blend mode, overprint, etc.) are deferred. +// +// Reference: PDF 1.7 specification, Section 8.4.5 (Graphics State Parameter +// Dictionaries) and Table 57 (Entries in a Graphics State Parameter Dictionary). +func (vp *VectorParser) applyExtGState(name string) { + if vp.pageRes == nil { + return + } + + // Navigate Resources → ExtGState → . + extGObj := vp.pageRes.Get("ExtGState") + if extGObj == nil { + return + } + + // Resolve indirect reference if needed. + if ref, ok := extGObj.(*parser.IndirectReference); ok { + resolved, err := vp.reader.GetObject(ref.Number) + if err != nil { + return + } + extGObj = resolved + } + + extGDict, ok := extGObj.(*parser.Dictionary) + if !ok { + return + } + + gsObj := extGDict.Get(name) + if gsObj == nil { + return + } + + // Resolve indirect reference for the graphics state dict itself. + if ref, ok := gsObj.(*parser.IndirectReference); ok { + resolved, err := vp.reader.GetObject(ref.Number) + if err != nil { + return + } + gsObj = resolved + } + + gsDict, ok := gsObj.(*parser.Dictionary) + if !ok { + return + } + + // /CA — stroke opacity (capital CA per PDF spec). + if caObj := gsDict.Get("CA"); caObj != nil { + if v := getNumber(caObj); v != nil { + vp.state.StrokeOpacity = clamp01(*v) + } + } + + // /ca — fill opacity (lowercase ca per PDF spec). + if caObj := gsDict.Get("ca"); caObj != nil { + if v := getNumber(caObj); v != nil { + vp.state.FillOpacity = clamp01(*v) + } + } +} + +// ── Color helpers ───────────────────────────────────────────────────────────── + +// cmykToRGB converts CMYK (0–1) to RGB (0–1). +// +// Formula: R = 1 − min(1, C+K), G = 1 − min(1, M+K), B = 1 − min(1, Y+K). +// +// Reference: PDF 1.7 specification, Section 10.3.5 (Conversion between Device +// Color Spaces). +func cmykToRGB(c, m, y, k float64) (r, g, b float64) { + r = 1 - math.Min(1, c+k) + g = 1 - math.Min(1, m+k) + b = 1 - math.Min(1, y+k) + return r, g, b +} + +// clamp01 restricts v to [0, 1]. +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} diff --git a/internal/extractor/vector_parser_test.go b/internal/extractor/vector_parser_test.go new file mode 100644 index 0000000..5036fb3 --- /dev/null +++ b/internal/extractor/vector_parser_test.go @@ -0,0 +1,666 @@ +package extractor + +import ( + "testing" + + "github.com/coregx/gxpdf/internal/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── PathVerb tests ──────────────────────────────────────────────────────────── + +func TestPathVerb_String(t *testing.T) { + tests := []struct { + verb PathVerb + want string + }{ + {VerbMoveTo, "MoveTo"}, + {VerbLineTo, "LineTo"}, + {VerbCubicTo, "CubicTo"}, + {VerbQuadTo, "QuadTo"}, + {VerbClose, "Close"}, + {PathVerb(99), "PathVerb(99)"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.verb.String()) + }) + } +} + +func TestVerbCoordCount(t *testing.T) { + tests := []struct { + verb PathVerb + count int + }{ + {VerbMoveTo, 2}, + {VerbLineTo, 2}, + {VerbCubicTo, 6}, + {VerbQuadTo, 4}, + {VerbClose, 0}, + {PathVerb(99), 0}, + } + + for _, tt := range tests { + t.Run(tt.verb.String(), func(t *testing.T) { + assert.Equal(t, tt.count, VerbCoordCount(tt.verb)) + }) + } +} + +// ── PaintMode tests ─────────────────────────────────────────────────────────── + +func TestPaintMode_String(t *testing.T) { + tests := []struct { + mode PaintMode + want string + }{ + {PaintStroke, "Stroke"}, + {PaintFill, "Fill"}, + {PaintFillStroke, "FillStroke"}, + {PaintMode(99), "PaintMode(99)"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.mode.String()) + }) + } +} + +// ── VectorPath tests ────────────────────────────────────────────────────────── + +func TestVectorPath_String(t *testing.T) { + vp := &VectorPath{ + PageNum: 0, + Verbs: []PathVerb{VerbMoveTo, VerbLineTo}, + Coords: []float64{0, 0, 100, 100}, + PaintMode: PaintStroke, + LineWidth: 1.5, + Opacity: 0.8, + MiterLimit: 10, + } + s := vp.String() + assert.Contains(t, s, "page=0") + assert.Contains(t, s, "verbs=2") + assert.Contains(t, s, "Stroke") + assert.Contains(t, s, "1.50") + assert.Contains(t, s, "0.80") +} + +// ── vectorStateStack tests ──────────────────────────────────────────────────── + +func TestVectorStateStack_SaveRestore(t *testing.T) { + var stack vectorStateStack + + gs1 := newVectorGraphicsState() + gs1.LineWidth = 2.5 + stack.save(gs1) + + gs2 := newVectorGraphicsState() + gs2.LineWidth = 5.0 + stack.save(gs2) + + restored, ok := stack.restore() + require.True(t, ok) + assert.InDelta(t, 5.0, restored.LineWidth, 1e-9) + + restored2, ok := stack.restore() + require.True(t, ok) + assert.InDelta(t, 2.5, restored2.LineWidth, 1e-9) + + // Stack empty — should return false. + _, ok = stack.restore() + assert.False(t, ok) +} + +func TestVectorStateStack_RestoreEmpty(t *testing.T) { + var stack vectorStateStack + _, ok := stack.restore() + assert.False(t, ok) +} + +// ── cmykToRGB tests ─────────────────────────────────────────────────────────── + +func TestCmykToRGB(t *testing.T) { + tests := []struct { + name string + c, m, y, k float64 + wantR, wantG, wantB float64 + }{ + { + name: "pure black (K=1)", + c: 0, m: 0, y: 0, k: 1, + wantR: 0, wantG: 0, wantB: 0, + }, + { + name: "pure white (all zeros)", + c: 0, m: 0, y: 0, k: 0, + wantR: 1, wantG: 1, wantB: 1, + }, + { + name: "pure cyan (C=1)", + c: 1, m: 0, y: 0, k: 0, + wantR: 0, wantG: 1, wantB: 1, + }, + { + name: "pure magenta (M=1)", + c: 0, m: 1, y: 0, k: 0, + wantR: 1, wantG: 0, wantB: 1, + }, + { + name: "pure yellow (Y=1)", + c: 0, m: 0, y: 1, k: 0, + wantR: 1, wantG: 1, wantB: 0, + }, + { + // C=0.8, K=0.5 → C+K=1.3 clamps to 1 → R=0 + // M=0, K=0.5 → M+K=0.5 → G=1-0.5=0.5 + // Y=0, K=0.5 → Y+K=0.5 → B=1-0.5=0.5 + name: "clamp: C+K > 1", + c: 0.8, m: 0, y: 0, k: 0.5, + wantR: 0, wantG: 0.5, wantB: 0.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, g, b := cmykToRGB(tt.c, tt.m, tt.y, tt.k) + assert.InDelta(t, tt.wantR, r, 1e-9) + assert.InDelta(t, tt.wantG, g, 1e-9) + assert.InDelta(t, tt.wantB, b, 1e-9) + }) + } +} + +// ── clamp01 tests ───────────────────────────────────────────────────────────── + +func TestClamp01(t *testing.T) { + assert.InDelta(t, 0.0, clamp01(-1.0), 1e-9) + assert.InDelta(t, 0.5, clamp01(0.5), 1e-9) + assert.InDelta(t, 1.0, clamp01(1.5), 1e-9) + assert.InDelta(t, 1.0, clamp01(1.0), 1e-9) + assert.InDelta(t, 0.0, clamp01(0.0), 1e-9) +} + +// ── VectorParser unit tests (operator processing) ───────────────────────────── + +// makeVectorParser creates a VectorParser with a pre-set state for unit testing +// without a real PDF reader. +func makeVectorParser() *VectorParser { + vp := &VectorParser{ + state: newVectorGraphicsState(), + } + return vp +} + +func TestVectorParser_GraphicsStateStack(t *testing.T) { + vp := makeVectorParser() + vp.state.LineWidth = 1.0 + + // q: save state. + vp.processVectorOperator(makeOp("q")) + vp.state.LineWidth = 3.0 + + // Q: restore state. + vp.processVectorOperator(makeOp("Q")) + assert.InDelta(t, 1.0, vp.state.LineWidth, 1e-9, "line width should be restored after Q") +} + +func TestVectorParser_Q_EmptyStack(t *testing.T) { + // Q on empty stack — should not panic. + vp := makeVectorParser() + original := vp.state.LineWidth + vp.processVectorOperator(makeOp("Q")) + assert.InDelta(t, original, vp.state.LineWidth, 1e-9, "state unchanged when Q on empty stack") +} + +func TestVectorParser_LineWidth(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("w", 3.5)) + assert.InDelta(t, 3.5, vp.state.LineWidth, 1e-9) +} + +func TestVectorParser_LineCap(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("J", 1)) + assert.Equal(t, 1, vp.state.LineCap) +} + +func TestVectorParser_LineJoin(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("j", 2)) + assert.Equal(t, 2, vp.state.LineJoin) +} + +func TestVectorParser_MiterLimit(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("M", 4.0)) + assert.InDelta(t, 4.0, vp.state.MiterLimit, 1e-9) +} + +func TestVectorParser_RGStrokeColor(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("RG", 0.2, 0.4, 0.6)) + assert.InDelta(t, 0.2, vp.state.StrokeColor[0], 1e-9) + assert.InDelta(t, 0.4, vp.state.StrokeColor[1], 1e-9) + assert.InDelta(t, 0.6, vp.state.StrokeColor[2], 1e-9) +} + +func TestVectorParser_rgFillColor(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("rg", 0.1, 0.2, 0.3)) + assert.InDelta(t, 0.1, vp.state.FillColor[0], 1e-9) + assert.InDelta(t, 0.2, vp.state.FillColor[1], 1e-9) + assert.InDelta(t, 0.3, vp.state.FillColor[2], 1e-9) +} + +func TestVectorParser_GGrayscaleStroke(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("G", 0.5)) + assert.InDelta(t, 0.5, vp.state.StrokeColor[0], 1e-9) + assert.InDelta(t, 0.5, vp.state.StrokeColor[1], 1e-9) + assert.InDelta(t, 0.5, vp.state.StrokeColor[2], 1e-9) +} + +func TestVectorParser_gGrayscaleFill(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("g", 0.75)) + assert.InDelta(t, 0.75, vp.state.FillColor[0], 1e-9) + assert.InDelta(t, 0.75, vp.state.FillColor[1], 1e-9) + assert.InDelta(t, 0.75, vp.state.FillColor[2], 1e-9) +} + +func TestVectorParser_KCMYKStroke(t *testing.T) { + vp := makeVectorParser() + // CMYK (0,0,0,1) → RGB (0,0,0) black. + vp.processVectorOperator(makeOpN("K", 0, 0, 0, 1)) + assert.InDelta(t, 0.0, vp.state.StrokeColor[0], 1e-9) + assert.InDelta(t, 0.0, vp.state.StrokeColor[1], 1e-9) + assert.InDelta(t, 0.0, vp.state.StrokeColor[2], 1e-9) +} + +func TestVectorParser_kCMYKFill(t *testing.T) { + vp := makeVectorParser() + // CMYK (0,0,0,0) → RGB (1,1,1) white. + vp.processVectorOperator(makeOpN("k", 0, 0, 0, 0)) + assert.InDelta(t, 1.0, vp.state.FillColor[0], 1e-9) + assert.InDelta(t, 1.0, vp.state.FillColor[1], 1e-9) + assert.InDelta(t, 1.0, vp.state.FillColor[2], 1e-9) +} + +func TestVectorParser_MoveTo(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 10, 20)) + require.Len(t, vp.curPath, 1) + assert.Equal(t, VerbMoveTo, vp.curPath[0].verb) + assert.InDelta(t, 10.0, vp.curPath[0].coords[0], 1e-9) + assert.InDelta(t, 20.0, vp.curPath[0].coords[1], 1e-9) + assert.True(t, vp.hasCur) +} + +func TestVectorParser_LineTo(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 50)) + require.Len(t, vp.curPath, 2) + assert.Equal(t, VerbLineTo, vp.curPath[1].verb) + assert.InDelta(t, 100.0, vp.curPath[1].coords[0], 1e-9) +} + +func TestVectorParser_CubicBezier_c(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + // c: c1x c1y c2x c2y x y + vp.processVectorOperator(makeOpN("c", 10, 20, 30, 40, 50, 60)) + require.Len(t, vp.curPath, 2) + seg := vp.curPath[1] + assert.Equal(t, VerbCubicTo, seg.verb) + assert.Len(t, seg.coords, 6) + assert.InDelta(t, 50.0, seg.coords[4], 1e-9) + assert.InDelta(t, 60.0, seg.coords[5], 1e-9) +} + +func TestVectorParser_CubicBezier_v(t *testing.T) { + // v: c1 = current point. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 10, 20)) // current = (10,20) + vp.processVectorOperator(makeOpN("v", 30, 40, 50, 60)) + require.Len(t, vp.curPath, 2) + seg := vp.curPath[1] + assert.Equal(t, VerbCubicTo, seg.verb) + // c1 should be (10,20) — the current point. + assert.InDelta(t, 10.0, seg.coords[0], 1e-9) + assert.InDelta(t, 20.0, seg.coords[1], 1e-9) +} + +func TestVectorParser_CubicBezier_y(t *testing.T) { + // y: c2 = endpoint. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("y", 10, 20, 50, 60)) + require.Len(t, vp.curPath, 2) + seg := vp.curPath[1] + assert.Equal(t, VerbCubicTo, seg.verb) + // c2 should equal endpoint (50,60). + assert.InDelta(t, 50.0, seg.coords[2], 1e-9) + assert.InDelta(t, 60.0, seg.coords[3], 1e-9) + assert.InDelta(t, 50.0, seg.coords[4], 1e-9) + assert.InDelta(t, 60.0, seg.coords[5], 1e-9) +} + +func TestVectorParser_CloseSubpath(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("h")) + require.Len(t, vp.curPath, 3) + assert.Equal(t, VerbClose, vp.curPath[2].verb) +} + +func TestVectorParser_Rectangle(t *testing.T) { + vp := makeVectorParser() + // re: x y w h + vp.processVectorOperator(makeOpN("re", 10, 20, 100, 50)) + // re expands to: m + 3×l + h = 5 segments. + require.Len(t, vp.curPath, 5) + assert.Equal(t, VerbMoveTo, vp.curPath[0].verb) + assert.Equal(t, VerbLineTo, vp.curPath[1].verb) + assert.Equal(t, VerbLineTo, vp.curPath[2].verb) + assert.Equal(t, VerbLineTo, vp.curPath[3].verb) + assert.Equal(t, VerbClose, vp.curPath[4].verb) +} + +func TestVectorParser_StrokePath(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("S")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintStroke, vp.paths[0].PaintMode) + assert.Empty(t, vp.curPath, "curPath should be cleared after S") +} + +func TestVectorParser_FillPath_f(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 10, 10, 50, 50)) + vp.processVectorOperator(makeOp("f")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFill, vp.paths[0].PaintMode) +} + +func TestVectorParser_FillPath_F(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 100, 100)) + vp.processVectorOperator(makeOp("F")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFill, vp.paths[0].PaintMode) +} + +func TestVectorParser_FillStrokePath_B(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 100, 100)) + vp.processVectorOperator(makeOp("B")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFillStroke, vp.paths[0].PaintMode) +} + +func TestVectorParser_FillStrokePath_BStar(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 100, 100)) + vp.processVectorOperator(makeOp("B*")) + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFillStroke, vp.paths[0].PaintMode) +} + +func TestVectorParser_CloseAndStroke_s(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 50, 50)) + vp.processVectorOperator(makeOp("s")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintStroke, vp.paths[0].PaintMode) + // The path should include the implicit close verb. + assert.Contains(t, vp.paths[0].Verbs, VerbClose) +} + +func TestVectorParser_CloseAndFillStroke_b(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 50, 0)) + vp.processVectorOperator(makeOp("b")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFillStroke, vp.paths[0].PaintMode) + assert.Contains(t, vp.paths[0].Verbs, VerbClose) +} + +func TestVectorParser_FillStarAndBStar(t *testing.T) { + // f* and b* are even-odd variants — same PaintMode output. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 10, 10)) + vp.processVectorOperator(makeOp("f*")) + require.Len(t, vp.paths, 1) + assert.Equal(t, PaintFill, vp.paths[0].PaintMode) + + vp2 := makeVectorParser() + vp2.processVectorOperator(makeOpN("m", 0, 0)) + vp2.processVectorOperator(makeOpN("l", 10, 0)) + vp2.processVectorOperator(makeOp("b*")) + require.Len(t, vp2.paths, 1) + assert.Equal(t, PaintFillStroke, vp2.paths[0].PaintMode) +} + +func TestVectorParser_NPath_Discards(t *testing.T) { + // n: end path without painting — must not produce a VectorPath. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("n")) + + assert.Empty(t, vp.paths) + assert.Empty(t, vp.curPath) +} + +func TestVectorParser_ClipPaths_Discarded(t *testing.T) { + // W and W* must not produce VectorPaths. + for _, clipOp := range []string{"W", "W*"} { + t.Run(clipOp, func(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 100, 100)) + vp.processVectorOperator(makeOp(clipOp)) + assert.Empty(t, vp.paths, "clip path operator %s should not produce VectorPath", clipOp) + }) + } +} + +func TestVectorParser_EmptyPath_NotEmitted(t *testing.T) { + // Calling S with no path should not produce output. + vp := makeVectorParser() + vp.processVectorOperator(makeOp("S")) + assert.Empty(t, vp.paths) +} + +func TestVectorParser_CTM_cm(t *testing.T) { + vp := makeVectorParser() + // Apply scaling matrix: scale x2. + // cm: a b c d e f (identity scaled by 2 = [2 0 0 2 0 0]). + vp.processVectorOperator(makeOpN("cm", 2, 0, 0, 2, 0, 0)) + + // Now m at (10, 20) should land at (20, 40) in page space. + vp.processVectorOperator(makeOpN("m", 10, 20)) + + require.Len(t, vp.curPath, 1) + assert.InDelta(t, 20.0, vp.curPath[0].coords[0], 1e-9) + assert.InDelta(t, 40.0, vp.curPath[0].coords[1], 1e-9) +} + +func TestVectorParser_CTM_SaveRestore(t *testing.T) { + // Ensure cm is saved and restored by q/Q. + vp := makeVectorParser() + vp.processVectorOperator(makeOp("q")) + vp.processVectorOperator(makeOpN("cm", 3, 0, 0, 3, 0, 0)) + + // Point (1,1) with scale=3 should be (3,3). + vp.processVectorOperator(makeOpN("m", 1, 1)) + assert.InDelta(t, 3.0, vp.curPath[0].coords[0], 1e-9) + + // Restore. + vp.processVectorOperator(makeOp("Q")) + vp.curPath = nil + vp.hasCur = false + + // Point (1,1) with identity CTM should still be (1,1). + vp.processVectorOperator(makeOpN("m", 1, 1)) + assert.InDelta(t, 1.0, vp.curPath[0].coords[0], 1e-9) +} + +func TestVectorParser_Opacity_Default(t *testing.T) { + // Without any ExtGState, opacity should be 1.0. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("re", 0, 0, 100, 100)) + vp.processVectorOperator(makeOp("f")) + + require.Len(t, vp.paths, 1) + assert.InDelta(t, 1.0, vp.paths[0].Opacity, 1e-9) +} + +func TestVectorParser_Colors_Captured(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("RG", 0.1, 0.2, 0.3)) // stroke + vp.processVectorOperator(makeOpN("rg", 0.4, 0.5, 0.6)) // fill + vp.processVectorOperator(makeOpN("re", 0, 0, 50, 50)) + vp.processVectorOperator(makeOp("B")) + + require.Len(t, vp.paths, 1) + p := vp.paths[0] + assert.InDelta(t, 0.1, p.StrokeColor[0], 1e-9) + assert.InDelta(t, 0.2, p.StrokeColor[1], 1e-9) + assert.InDelta(t, 0.3, p.StrokeColor[2], 1e-9) + assert.InDelta(t, 0.4, p.FillColor[0], 1e-9) + assert.InDelta(t, 0.5, p.FillColor[1], 1e-9) + assert.InDelta(t, 0.6, p.FillColor[2], 1e-9) +} + +func TestVectorParser_VerbCoordsConsistency(t *testing.T) { + // All coord counts must match verb definitions. + vp := makeVectorParser() + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 50, 0)) + vp.processVectorOperator(makeOpN("c", 60, 10, 70, 10, 80, 0)) + vp.processVectorOperator(makeOp("h")) + vp.processVectorOperator(makeOp("S")) + + require.Len(t, vp.paths, 1) + path := vp.paths[0] + + coordIdx := 0 + for _, verb := range path.Verbs { + n := VerbCoordCount(verb) + assert.GreaterOrEqual(t, len(path.Coords)-coordIdx, n, + "not enough coords for verb %s at index %d", verb, coordIdx) + coordIdx += n + } + assert.Equal(t, len(path.Coords), coordIdx, "all coords consumed") +} + +func TestVectorParser_LineWidthInPath(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("w", 3.0)) + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("S")) + + require.Len(t, vp.paths, 1) + assert.InDelta(t, 3.0, vp.paths[0].LineWidth, 1e-9) +} + +func TestVectorParser_LineCapJoinMiterInPath(t *testing.T) { + vp := makeVectorParser() + vp.processVectorOperator(makeOpF("J", 1)) // round cap + vp.processVectorOperator(makeOpF("j", 2)) // bevel join + vp.processVectorOperator(makeOpF("M", 8.0)) // miter limit + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("S")) + + require.Len(t, vp.paths, 1) + assert.Equal(t, 1, vp.paths[0].LineCap) + assert.Equal(t, 2, vp.paths[0].LineJoin) + assert.InDelta(t, 8.0, vp.paths[0].MiterLimit, 1e-9) +} + +func TestVectorParser_CMYK_Stroke(t *testing.T) { + vp := makeVectorParser() + // K 0 0 0 1 → black. + vp.processVectorOperator(makeOpN("K", 0, 0, 0, 1)) + vp.processVectorOperator(makeOpN("m", 0, 0)) + vp.processVectorOperator(makeOpN("l", 100, 0)) + vp.processVectorOperator(makeOp("S")) + + require.Len(t, vp.paths, 1) + assert.InDelta(t, 0.0, vp.paths[0].StrokeColor[0], 1e-9) + assert.InDelta(t, 0.0, vp.paths[0].StrokeColor[1], 1e-9) + assert.InDelta(t, 0.0, vp.paths[0].StrokeColor[2], 1e-9) +} + +func TestVectorParser_CMYK_Fill(t *testing.T) { + vp := makeVectorParser() + // k 1 0 0 0 → cyan. + vp.processVectorOperator(makeOpN("k", 1, 0, 0, 0)) + vp.processVectorOperator(makeOpN("re", 0, 0, 50, 50)) + vp.processVectorOperator(makeOp("f")) + + require.Len(t, vp.paths, 1) + assert.InDelta(t, 0.0, vp.paths[0].FillColor[0], 1e-9) + assert.InDelta(t, 1.0, vp.paths[0].FillColor[1], 1e-9) + assert.InDelta(t, 1.0, vp.paths[0].FillColor[2], 1e-9) +} + +func TestVectorParser_d_Noop(t *testing.T) { + // d (dash pattern) should not crash. + vp := makeVectorParser() + // The d operator takes an array and a phase, but our content parser will + // have already parsed them — we just pass two operands. + op := &Operator{Name: "d", Operands: nil} + assert.NotPanics(t, func() { vp.processVectorOperator(op) }) +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +// makeOp creates an operator with no operands. +func makeOp(name string) *Operator { + return &Operator{Name: name} +} + +// makeOpF creates an operator with a single float64 operand. +func makeOpF(name string, v float64) *Operator { + return &Operator{ + Name: name, + Operands: floatOperands(v), + } +} + +// makeOpN creates an operator with multiple float64 operands. +func makeOpN(name string, vals ...float64) *Operator { + return &Operator{ + Name: name, + Operands: floatOperands(vals...), + } +} + +// floatOperands converts a variadic list of float64 values to PdfObject operands. +func floatOperands(vals ...float64) []parser.PdfObject { + objs := make([]parser.PdfObject, len(vals)) + for i, v := range vals { + objs[i] = parser.NewReal(v) + } + return objs +} diff --git a/internal/extractor/vector_path.go b/internal/extractor/vector_path.go new file mode 100644 index 0000000..56f03a0 --- /dev/null +++ b/internal/extractor/vector_path.go @@ -0,0 +1,181 @@ +package extractor + +import "fmt" + +// PathVerb identifies a single step in a vector path. +// +// The verb+coords model is compatible with the gogpu/gg PathVerb pattern: +// each verb describes the operation and how many coordinate values follow +// in the flattened Coords slice. +// +// - VerbMoveTo — 2 coords: x, y +// - VerbLineTo — 2 coords: x, y +// - VerbCubicTo — 6 coords: c1x, c1y, c2x, c2y, x, y +// - VerbQuadTo — 4 coords: cx, cy, x, y +// - VerbClose — 0 coords +// +// Reference: PDF 1.7 specification, Section 8.5.2 (Path Construction Operators). +type PathVerb int + +const ( + // VerbMoveTo starts a new subpath at (x, y). + // Consumes 2 coords: x, y. + VerbMoveTo PathVerb = iota + + // VerbLineTo adds a straight-line segment to (x, y). + // Consumes 2 coords: x, y. + VerbLineTo + + // VerbCubicTo adds a cubic Bézier segment. + // Consumes 6 coords: c1x, c1y, c2x, c2y, x, y. + // PDF operators: c (full), v (c1=current point), y (c2=endpoint). + VerbCubicTo + + // VerbQuadTo adds a quadratic Bézier segment. + // Consumes 4 coords: cx, cy, x, y. + // Produced by approximating when the PDF source already encodes quads. + VerbQuadTo + + // VerbClose closes the current subpath with a straight line back to start. + // Consumes 0 coords. + // PDF operator: h. + VerbClose +) + +// String returns a human-readable label for the verb. +func (v PathVerb) String() string { + switch v { + case VerbMoveTo: + return "MoveTo" + case VerbLineTo: + return "LineTo" + case VerbCubicTo: + return "CubicTo" + case VerbQuadTo: + return "QuadTo" + case VerbClose: + return "Close" + default: + return fmt.Sprintf("PathVerb(%d)", int(v)) + } +} + +// VerbCoordCount returns the number of coordinate values consumed by the verb. +func VerbCoordCount(v PathVerb) int { + switch v { + case VerbMoveTo: + return 2 + case VerbLineTo: + return 2 + case VerbCubicTo: + return 6 + case VerbQuadTo: + return 4 + case VerbClose: + return 0 + default: + return 0 + } +} + +// PaintMode describes how a path is painted. +// +// Reference: PDF 1.7 specification, Section 8.5.3 (Path Painting Operators). +type PaintMode int + +const ( + // PaintStroke strokes the path outline. + // PDF operators: S, s. + PaintStroke PaintMode = iota + + // PaintFill fills the path interior. + // PDF operators: f, F, f*. + PaintFill + + // PaintFillStroke fills and strokes the path. + // PDF operators: B, B*, b, b*. + PaintFillStroke +) + +// String returns a human-readable label for the paint mode. +func (pm PaintMode) String() string { + switch pm { + case PaintStroke: + return "Stroke" + case PaintFill: + return "Fill" + case PaintFillStroke: + return "FillStroke" + default: + return fmt.Sprintf("PaintMode(%d)", int(pm)) + } +} + +// VectorPath represents a fully resolved vector path extracted from a PDF content stream. +// +// All coordinates are in page user space (points, origin at bottom-left), +// after the Current Transformation Matrix (CTM) has been applied. +// +// The verb+coords encoding is compact and compatible with gogpu/gg rendering: +// - Iterate Verbs; for each verb consume VerbCoordCount(verb) values from Coords. +// +// Example — a filled blue rectangle: +// +// path.Verbs = [VerbMoveTo, VerbLineTo, VerbLineTo, VerbLineTo, VerbClose] +// path.Coords = [x0, y0, x1, y1, x2, y2, x3, y3] +// path.FillColor = [0, 0, 1, 1] // blue, fully opaque +// path.PaintMode = PaintFill +type VectorPath struct { + // PageNum is the 0-based page index this path was extracted from. + PageNum int + + // Verbs is the sequence of path construction verbs. + // Each verb describes the type of segment and how many Coords it consumes. + Verbs []PathVerb + + // Coords stores the flattened coordinates for all verbs. + // Coordinate count per verb: see VerbCoordCount. + Coords []float64 + + // StrokeColor is the stroke color in RGBA (components 0–1). + // Alpha is always 1.0 for DeviceRGB/DeviceGray/DeviceCMYK sources; + // it reflects the ExtGState /CA value when set. + StrokeColor [4]float64 + + // FillColor is the fill color in RGBA (components 0–1). + // Alpha reflects the ExtGState /ca value when set. + FillColor [4]float64 + + // LineWidth is the stroke line width in points. + LineWidth float64 + + // LineCap is the PDF line cap style (0=butt, 1=round, 2=square). + LineCap int + + // LineJoin is the PDF line join style (0=miter, 1=round, 2=bevel). + LineJoin int + + // MiterLimit is the miter join limit (default 10.0 per PDF spec). + MiterLimit float64 + + // Opacity is the effective opacity (0=transparent, 1=opaque). + // Derived from ExtGState /ca for fill or /CA for stroke (whichever is + // more restrictive). Defaults to 1.0 when no ExtGState is present. + Opacity float64 + + // PaintMode describes whether the path is stroked, filled, or both. + PaintMode PaintMode +} + +// String returns a compact human-readable representation of the vector path. +func (vp *VectorPath) String() string { + return fmt.Sprintf( + "VectorPath{page=%d verbs=%d coords=%d paint=%s lw=%.2f opacity=%.2f}", + vp.PageNum, + len(vp.Verbs), + len(vp.Coords), + vp.PaintMode.String(), + vp.LineWidth, + vp.Opacity, + ) +} diff --git a/vector_graphics.go b/vector_graphics.go new file mode 100644 index 0000000..b174271 --- /dev/null +++ b/vector_graphics.go @@ -0,0 +1,252 @@ +package gxpdf + +import ( + "fmt" + + "github.com/coregx/gxpdf/internal/extractor" + "github.com/coregx/gxpdf/logging" +) + +// PathVerb identifies the kind of step in a vector path. +// +// The verb+coords model is compatible with the gogpu/gg PathVerb pattern. +// Each verb describes one path operation and the number of coordinate values +// that follow it in the VectorPath.Coords slice. +// +// - PathVerbMoveTo — 2 coords: x, y +// - PathVerbLineTo — 2 coords: x, y +// - PathVerbCubicTo — 6 coords: c1x, c1y, c2x, c2y, x, y +// - PathVerbQuadTo — 4 coords: cx, cy, x, y +// - PathVerbClose — 0 coords +// +// Reference: PDF 1.7 specification, Section 8.5.2 (Path Construction Operators). +type PathVerb int + +const ( + // PathVerbMoveTo starts a new subpath at (x, y). Consumes 2 coords. + PathVerbMoveTo PathVerb = PathVerb(extractor.VerbMoveTo) + + // PathVerbLineTo adds a straight-line segment to (x, y). Consumes 2 coords. + PathVerbLineTo PathVerb = PathVerb(extractor.VerbLineTo) + + // PathVerbCubicTo adds a cubic Bézier segment. Consumes 6 coords: + // c1x, c1y, c2x, c2y, x, y. + PathVerbCubicTo PathVerb = PathVerb(extractor.VerbCubicTo) + + // PathVerbQuadTo adds a quadratic Bézier segment. Consumes 4 coords: + // cx, cy, x, y. + PathVerbQuadTo PathVerb = PathVerb(extractor.VerbQuadTo) + + // PathVerbClose closes the current subpath. Consumes 0 coords. + PathVerbClose PathVerb = PathVerb(extractor.VerbClose) +) + +// String returns a human-readable label for the verb. +func (v PathVerb) String() string { + return extractor.PathVerb(v).String() +} + +// VerbCoordCount returns the number of coordinate values consumed by this verb. +func VerbCoordCount(v PathVerb) int { + return extractor.VerbCoordCount(extractor.PathVerb(v)) +} + +// PaintMode describes how a vector path is painted. +// +// Reference: PDF 1.7 specification, Section 8.5.3 (Path Painting Operators). +type PaintMode int + +const ( + // PaintModeStroke strokes the path outline. + PaintModeStroke PaintMode = PaintMode(extractor.PaintStroke) + + // PaintModeFill fills the path interior. + PaintModeFill PaintMode = PaintMode(extractor.PaintFill) + + // PaintModeFillStroke fills and strokes the path. + PaintModeFillStroke PaintMode = PaintMode(extractor.PaintFillStroke) +) + +// String returns a human-readable label for the paint mode. +func (pm PaintMode) String() string { + return extractor.PaintMode(pm).String() +} + +// VectorPath represents a fully resolved vector graphics path extracted from a +// PDF page, with all coordinates transformed into page user space. +// +// Coordinate system: points (1/72 inch), origin at bottom-left of page. +// All coordinates are post-CTM (Current Transformation Matrix) — they are +// already in absolute page space and require no further transformation. +// +// Iterating verbs and coords: +// +// idx := 0 +// for _, verb := range path.Verbs { +// n := gxpdf.VerbCoordCount(verb) +// coords := path.Coords[idx : idx+n] +// idx += n +// switch verb { +// case gxpdf.PathVerbMoveTo: +// // coords[0]=x, coords[1]=y +// case gxpdf.PathVerbLineTo: +// // coords[0]=x, coords[1]=y +// case gxpdf.PathVerbCubicTo: +// // coords[0..1]=c1, coords[2..3]=c2, coords[4..5]=endpoint +// case gxpdf.PathVerbClose: +// // no coords +// } +// } +type VectorPath struct { + // PageNum is the 0-based page index this path was extracted from. + PageNum int + + // Verbs is the sequence of path construction operations. + // Use VerbCoordCount(verb) to determine how many Coords each verb consumes. + Verbs []PathVerb + + // Coords stores the flattened coordinates for all verbs in page space. + Coords []float64 + + // StrokeColor is the stroke color in RGBA (components 0–1). + // The alpha channel reflects the /CA ExtGState opacity (1.0 if absent). + StrokeColor [4]float64 + + // FillColor is the fill color in RGBA (components 0–1). + // The alpha channel reflects the /ca ExtGState opacity (1.0 if absent). + FillColor [4]float64 + + // LineWidth is the stroke width in points. + LineWidth float64 + + // LineCap is the PDF line cap style: 0=butt, 1=round, 2=projecting square. + LineCap int + + // LineJoin is the PDF line join style: 0=miter, 1=round, 2=bevel. + LineJoin int + + // MiterLimit is the miter join limit (PDF default: 10.0). + MiterLimit float64 + + // Opacity is the effective opacity (0=transparent, 1=opaque). + // Derived from ExtGState parameters; defaults to 1.0. + Opacity float64 + + // PaintMode indicates how the path is rendered. + PaintMode PaintMode +} + +// String returns a compact human-readable representation of the vector path. +func (vp *VectorPath) String() string { + return fmt.Sprintf( + "VectorPath{page=%d verbs=%d paint=%s lw=%.2f opacity=%.2f}", + vp.PageNum, + len(vp.Verbs), + vp.PaintMode.String(), + vp.LineWidth, + vp.Opacity, + ) +} + +// GetVectorGraphics extracts all vector graphics paths from all pages. +// +// This is the simplest way to extract vector graphics. It processes all pages +// and returns all paths found across the entire document. +// +// Errors during extraction are logged via slog. +// For error handling, use GetVectorGraphicsWithError. +// +// Example: +// +// doc, err := gxpdf.Open("diagram.pdf") +// if err != nil { +// log.Fatal(err) +// } +// defer doc.Close() +// +// paths := doc.GetVectorGraphics() +// for _, p := range paths { +// fmt.Printf("Page %d: %s\n", p.PageNum, p.PaintMode) +// } +func (d *Document) GetVectorGraphics() []*VectorPath { + paths, err := d.GetVectorGraphicsWithError() + if err != nil { + logging.Logger().Error("failed to extract vector graphics", + "path", d.path, + "error", err) + } + return paths +} + +// GetVectorGraphicsWithError extracts all vector graphics paths from all pages, +// returning any errors encountered. +// +// Use this when you need structured error handling. +func (d *Document) GetVectorGraphicsWithError() ([]*VectorPath, error) { + count := d.PageCount() + var all []*VectorPath + for i := 0; i < count; i++ { + pagePaths, err := d.GetVectorGraphicsForPage(i) + if err != nil { + return nil, fmt.Errorf("gxpdf: failed to extract vector graphics from page %d: %w", i, err) + } + all = append(all, pagePaths...) + } + return all, nil +} + +// GetVectorGraphicsForPage extracts vector graphics paths from a single page. +// +// pageNum is 0-based (first page = 0). +// +// Example: +// +// paths, err := doc.GetVectorGraphicsForPage(0) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Page 0 has %d vector paths\n", len(paths)) +func (d *Document) GetVectorGraphicsForPage(pageNum int) ([]*VectorPath, error) { + if pageNum < 0 || pageNum >= d.PageCount() { + return nil, fmt.Errorf("gxpdf: page %d out of range (0-%d)", pageNum, d.PageCount()-1) + } + + vparser := extractor.NewVectorParser(d.reader) + internalPaths, err := vparser.ParseFromPage(pageNum) + if err != nil { + return nil, fmt.Errorf("gxpdf: failed to extract vector graphics from page %d: %w", pageNum, err) + } + + // Convert internal paths to public API paths. + result := make([]*VectorPath, len(internalPaths)) + for i, ip := range internalPaths { + result[i] = internalToPublicVectorPath(ip) + } + return result, nil +} + +// internalToPublicVectorPath converts an internal VectorPath to the public type. +func internalToPublicVectorPath(ip *extractor.VectorPath) *VectorPath { + verbs := make([]PathVerb, len(ip.Verbs)) + for i, v := range ip.Verbs { + verbs[i] = PathVerb(v) + } + + // Copy coords slice to avoid shared backing array. + coords := make([]float64, len(ip.Coords)) + copy(coords, ip.Coords) + + return &VectorPath{ + PageNum: ip.PageNum, + Verbs: verbs, + Coords: coords, + StrokeColor: ip.StrokeColor, + FillColor: ip.FillColor, + LineWidth: ip.LineWidth, + LineCap: ip.LineCap, + LineJoin: ip.LineJoin, + MiterLimit: ip.MiterLimit, + Opacity: ip.Opacity, + PaintMode: PaintMode(ip.PaintMode), + } +} diff --git a/vector_graphics_test.go b/vector_graphics_test.go new file mode 100644 index 0000000..01bf0bb --- /dev/null +++ b/vector_graphics_test.go @@ -0,0 +1,312 @@ +package gxpdf_test + +import ( + "bytes" + "os" + "testing" + + "github.com/coregx/gxpdf" + "github.com/coregx/gxpdf/creator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createVectorTestPDF builds a small PDF in memory using the Creator API and +// returns its raw bytes. The document contains: +// - A stroked horizontal line (black, 2pt width) +// - A filled rectangle (blue fill) +// - A stroked+filled rectangle (red stroke, green fill) +// - A cubic Bézier curve +func createVectorTestPDF(t *testing.T) []byte { + t.Helper() + + c := creator.New() + + page, err := c.NewPage() + require.NoError(t, err, "NewPage failed") + + // Black horizontal line, 2pt wide. + lineOpts := &creator.LineOptions{ + Color: creator.Color{R: 0, G: 0, B: 0}, + Width: 2.0, + } + err = page.DrawLine(50, 700, 400, 700, lineOpts) + require.NoError(t, err, "DrawLine failed") + + // Blue filled rectangle. + rectOpts := &creator.RectOptions{ + FillColor: &creator.Color{R: 0, G: 0, B: 1}, + } + err = page.DrawRect(100, 600, 150, 80, rectOpts) + require.NoError(t, err, "DrawRect (fill) failed") + + // Red-stroked, green-filled rectangle. + rectOpts2 := &creator.RectOptions{ + StrokeColor: &creator.Color{R: 1, G: 0, B: 0}, + FillColor: &creator.Color{R: 0, G: 1, B: 0}, + StrokeWidth: 1.5, + } + err = page.DrawRect(300, 600, 150, 80, rectOpts2) + require.NoError(t, err, "DrawRect (fill+stroke) failed") + + // Cubic Bézier curve. + segments := []creator.BezierSegment{ + { + Start: creator.Point{X: 50, Y: 500}, + C1: creator.Point{X: 100, Y: 550}, + C2: creator.Point{X: 200, Y: 450}, + End: creator.Point{X: 300, Y: 500}, + }, + } + bezierOpts := &creator.BezierOptions{ + Color: creator.Color{R: 0.5, G: 0, B: 0.5}, + Width: 1.0, + } + err = page.DrawBezierCurve(segments, bezierOpts) + require.NoError(t, err, "DrawBezierCurve failed") + + var buf bytes.Buffer + _, err = c.WriteTo(&buf) + require.NoError(t, err, "WriteTo failed") + + return buf.Bytes() +} + +// openFromBytes opens a PDF from in-memory bytes. +// It writes the bytes to a temp file that is cleaned up after the test. +func openFromBytes(t *testing.T, data []byte) *gxpdf.Document { + t.Helper() + + tmpFile := t.TempDir() + "/test.pdf" + err := writeBytesToFile(tmpFile, data) + require.NoError(t, err, "writeBytesToFile failed") + + doc, err := gxpdf.Open(tmpFile) + require.NoError(t, err, "gxpdf.Open failed") + t.Cleanup(func() { _ = doc.Close() }) + return doc +} + +func writeBytesToFile(path string, data []byte) error { + return os.WriteFile(path, data, 0o644) +} + +// ── PathVerb public API tests ───────────────────────────────────────────────── + +func TestPathVerb_String_Public(t *testing.T) { + tests := []struct { + verb gxpdf.PathVerb + want string + }{ + {gxpdf.PathVerbMoveTo, "MoveTo"}, + {gxpdf.PathVerbLineTo, "LineTo"}, + {gxpdf.PathVerbCubicTo, "CubicTo"}, + {gxpdf.PathVerbQuadTo, "QuadTo"}, + {gxpdf.PathVerbClose, "Close"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.verb.String()) + }) + } +} + +func TestVerbCoordCount_Public(t *testing.T) { + assert.Equal(t, 2, gxpdf.VerbCoordCount(gxpdf.PathVerbMoveTo)) + assert.Equal(t, 2, gxpdf.VerbCoordCount(gxpdf.PathVerbLineTo)) + assert.Equal(t, 6, gxpdf.VerbCoordCount(gxpdf.PathVerbCubicTo)) + assert.Equal(t, 4, gxpdf.VerbCoordCount(gxpdf.PathVerbQuadTo)) + assert.Equal(t, 0, gxpdf.VerbCoordCount(gxpdf.PathVerbClose)) +} + +func TestPaintMode_String_Public(t *testing.T) { + assert.Equal(t, "Stroke", gxpdf.PaintModeStroke.String()) + assert.Equal(t, "Fill", gxpdf.PaintModeFill.String()) + assert.Equal(t, "FillStroke", gxpdf.PaintModeFillStroke.String()) +} + +// ── VectorPath String ───────────────────────────────────────────────────────── + +func TestVectorPath_String_Public(t *testing.T) { + vp := &gxpdf.VectorPath{ + PageNum: 0, + Verbs: []gxpdf.PathVerb{gxpdf.PathVerbMoveTo, gxpdf.PathVerbLineTo}, + PaintMode: gxpdf.PaintModeStroke, + LineWidth: 2.0, + Opacity: 1.0, + } + s := vp.String() + assert.Contains(t, s, "page=0") + assert.Contains(t, s, "verbs=2") + assert.Contains(t, s, "Stroke") +} + +// ── GetVectorGraphicsForPage out-of-range ───────────────────────────────────── + +func TestDocument_GetVectorGraphicsForPage_OutOfRange(t *testing.T) { + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + _, err := doc.GetVectorGraphicsForPage(-1) + assert.Error(t, err, "negative page should return error") + + _, err = doc.GetVectorGraphicsForPage(9999) + assert.Error(t, err, "page beyond count should return error") +} + +// ── Round-trip: Creator → extract ──────────────────────────────────────────── + +func TestGetVectorGraphicsForPage_ReturnsNonEmptySlice(t *testing.T) { + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + assert.NotEmpty(t, paths, "expected at least one vector path on page 0") +} + +func TestGetVectorGraphics_AllPages(t *testing.T) { + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths := doc.GetVectorGraphics() + assert.NotEmpty(t, paths) +} + +func TestGetVectorGraphicsWithError_AllPages(t *testing.T) { + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsWithError() + require.NoError(t, err) + assert.NotEmpty(t, paths) +} + +func TestGetVectorGraphicsForPage_PageNumSet(t *testing.T) { + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + for _, p := range paths { + assert.Equal(t, 0, p.PageNum, "all paths should be from page 0") + } +} + +func TestGetVectorGraphicsForPage_VerbCoordsConsistency(t *testing.T) { + // Every verb must have the correct number of following coords. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + for _, path := range paths { + idx := 0 + for vi, verb := range path.Verbs { + n := gxpdf.VerbCoordCount(verb) + assert.GreaterOrEqual(t, len(path.Coords)-idx, n, + "path verb[%d]=%s needs %d coords but only %d remain", + vi, verb, n, len(path.Coords)-idx) + idx += n + } + assert.Equal(t, len(path.Coords), idx, + "all %d coords must be consumed by verbs", len(path.Coords)) + } +} + +func TestGetVectorGraphicsForPage_HasStrokePaths(t *testing.T) { + // The test PDF has a DrawLine which should produce a stroke path. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + var foundStroke bool + for _, p := range paths { + if p.PaintMode == gxpdf.PaintModeStroke { + foundStroke = true + break + } + } + assert.True(t, foundStroke, "expected at least one stroke path (from DrawLine)") +} + +func TestGetVectorGraphicsForPage_HasFillPaths(t *testing.T) { + // The test PDF has a blue filled rectangle. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + var foundFill bool + for _, p := range paths { + if p.PaintMode == gxpdf.PaintModeFill || p.PaintMode == gxpdf.PaintModeFillStroke { + foundFill = true + break + } + } + assert.True(t, foundFill, "expected at least one fill path (from DrawRect blue fill)") +} + +func TestGetVectorGraphicsForPage_HasCubicBezier(t *testing.T) { + // The test PDF has a Bézier curve drawn via DrawBezierCurve. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + var foundCubic bool + for _, path := range paths { + for _, verb := range path.Verbs { + if verb == gxpdf.PathVerbCubicTo { + foundCubic = true + break + } + } + if foundCubic { + break + } + } + assert.True(t, foundCubic, "expected at least one CubicTo verb from DrawBezierCurve") +} + +func TestGetVectorGraphicsForPage_ColorValues(t *testing.T) { + // Paths must have valid RGB color values in [0,1]. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + + for _, p := range paths { + for i, v := range p.StrokeColor { + assert.GreaterOrEqual(t, v, 0.0, "StrokeColor[%d] out of range", i) + assert.LessOrEqual(t, v, 1.0, "StrokeColor[%d] out of range", i) + } + for i, v := range p.FillColor { + assert.GreaterOrEqual(t, v, 0.0, "FillColor[%d] out of range", i) + assert.LessOrEqual(t, v, 1.0, "FillColor[%d] out of range", i) + } + assert.GreaterOrEqual(t, p.Opacity, 0.0) + assert.LessOrEqual(t, p.Opacity, 1.0) + assert.GreaterOrEqual(t, p.LineWidth, 0.0) + } +} + +func TestGetVectorGraphicsForPage_DefaultMiterLimit(t *testing.T) { + // PDF default miter limit is 10.0. + data := createVectorTestPDF(t) + doc := openFromBytes(t, data) + + paths, err := doc.GetVectorGraphicsForPage(0) + require.NoError(t, err) + require.NotEmpty(t, paths) + + // At least the first path should have the default miter limit. + assert.InDelta(t, 10.0, paths[0].MiterLimit, 1e-9) +}