diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d11fb..f597e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Positioned Text Extraction** — `Page.ExtractTextElements()` and `Document.ExtractTextElementsFromPage()` return text runs with X, Y, Width, Height, FontName, FontSize (#68) +- **In-Memory PDF Opening** — `OpenFromBytes()`, `OpenFromBytesWithPassword()`, and context-aware variants for server-side workflows without temp file I/O (#68) + ### Changed +- **Parser Reader Refactor** — Internal `Reader` refactored from `*os.File` to `io.ReadSeeker` interface, enabling in-memory PDF support - **Test Coverage** — Project-wide coverage raised from 53.7% to 86.5% - 11,200+ lines of new enterprise-grade tests across 12 packages - All non-example packages now above 80% coverage threshold diff --git a/README.md b/README.md index 2b9c7ff..e1e3e20 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,36 @@ for _, page := range doc.Pages() { } ``` +### Positioned Text Extraction (NEW) + +```go +doc, _ := gxpdf.Open("document.pdf") +defer doc.Close() + +// Get text with positions, sizes, and font info +elements, _ := doc.ExtractTextElementsFromPage(1) +for _, e := range elements { + fmt.Printf("%q at (%.1f, %.1f) font=%s size=%.1f\n", + e.Text, e.X, e.Y, e.FontName, e.FontSize) +} +``` + +### Opening PDFs from Memory (NEW) + +```go +// Read PDF from HTTP request, database, or any byte source +data, _ := io.ReadAll(httpResponse.Body) + +doc, err := gxpdf.OpenFromBytes(data) +if err != nil { + log.Fatal(err) +} +defer doc.Close() + +// Works with encrypted PDFs too +doc, _ = gxpdf.OpenFromBytesWithPassword(data, "secret") +``` + ### Extracting Tables from PDFs ```go diff --git a/ROADMAP.md b/ROADMAP.md index 0b22df6..c4b8376 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,39 +130,42 @@ Page sizes, custom dimensions, landscape orientation, and text rotation: ## Current Development -### Unreleased (on main) +### v0.7.0 "Builder & Signatures" + +**Released**: March 2026 -- **Arc Drawing** (#59) — elliptical/circular arcs with wedge/chord fill modes - **Declarative Builder API** — QuestPDF-inspired layout with 12-col grid, auto-pagination - **Enterprise Tables** — colspan, rowspan, header repeat, page split -- **Rich Text** — multi-style inline text with mixed bold/italic/color -- **Digital Signatures** — PAdES B-B/B-T, RSA + ECDSA, zero external deps +- **Rich Text** — mixed-style inline text with baseline alignment, justify +- **Digital Signatures** — PAdES B-B + B-T, CMS/PKCS#7, RFC 3161, zero deps +- **Arc Drawing** (#59) — elliptical/circular arcs with wedge/chord fill modes +- **Text Measurement API** — exported font metrics (Standard 14 + TTF) -### Planned +### Unreleased (on main) -#### v0.7.0 - "Builder & Signatures" +- **Positioned Text Extraction** (#68) — `ExtractTextElements()` with X, Y, Width, Height, FontName, FontSize +- **In-Memory PDF Opening** (#68) — `OpenFromBytes()` for server-side workflows +- **Embedded Font Extraction** (#67) — extract TTF/OTF font binaries for round-trip preservation +- **Vector Graphics Extraction** (#66) — extract paths, bezier curves, colors, opacity with CTM support -**Done (unreleased on main)** +### Planned -- **Declarative Builder API** — QuestPDF-inspired layout with 12-col grid, auto-pagination - - `layout/` pure computation engine (85.7% coverage) - - `builder/` user-facing API (80.6% coverage) - - Own types (Value, Color, Size) — no layout/ import leak - - Font measurement bridge (Standard 14 + TTF) -- **Enterprise Tables** — colspan, rowspan, header repeat on overflow, page split, auto/fixed/fr columns -- **Rich Text** — mixed-style inline text with baseline alignment, justify -- **Digital Signatures** — PAdES B-B + B-T, CMS/PKCS#7, RFC 3161, zero deps (80.7% coverage) -- **Text Measurement API** — exported font metrics (Standard 14 + TTF) -- **Half-leading** — optically centered text in line boxes +#### v0.8.0 - "Extraction & Access" + +- **Positioned Text Extraction** (#68) — public API for text elements with positions +- **In-Memory PDF Opening** (#68) — `OpenFromBytes()` without temp files +- **Embedded Font Extraction** (#67) — TTF round-trip extraction +- **Vector Graphics Extraction** (#66) — path verbs, CTM, q/Q stack, opacity +- **Scientific Paper Text Extraction** — two-column layout detection, reading order +- **Scientific Metadata Extraction** — title, authors, abstract, DOI via font heuristics -#### v0.8.0 - "Generation Platform" +#### v0.9.0 - "Generation Platform" - **HTML to PDF** — render HTML/CSS into PDF via Builder API - **QR Code + Barcode** — QR, Code128, EAN-13 generation - **PDF/A Compliance** — archival format (A-1b, A-2b) - **PDF/UA Accessibility** — tagged PDF for screen readers - **Ready Components** — invoice, report, letter templates -- **HTML to PDF** - Render WYSIWYG HTML into PDF (may be separate library) #### v1.0.0 - Stable Release @@ -212,16 +215,21 @@ Page sizes, custom dimensions, landscape orientation, and text rotation: | Text Opacity | Done | v0.5.0 | | Gradient Rendering (PDF Shading) | Done | v0.6.0 | | Encrypted PDF Reading | Done | v0.6.0 | -| Arc Drawing (elliptical/circular) | Done | unreleased | -| Declarative Builder API | Done | unreleased | -| Tables with ColSpan/RowSpan | Planned | v0.7.0 | -| Rich Text (mixed inline styles) | Planned | v0.7.0 | -| Digital Signatures | Planned | v0.7.0 | -| HTML to PDF | Planned | v0.8.0 | -| PDF/A Compliance | Planned | v0.8.0 | -| PDF Render to Image | Planned | v0.8.0 | -| SVG Import | Planned | v0.8.0 | -| Barcode / QR Code | Planned | v0.8.0 | +| Arc Drawing (elliptical/circular) | Done | v0.7.0 | +| Declarative Builder API | Done | v0.7.0 | +| Tables with ColSpan/RowSpan | Done | v0.7.0 | +| Rich Text (mixed inline styles) | Done | v0.7.0 | +| Digital Signatures (PAdES) | Done | v0.7.0 | +| Positioned Text Extraction | Done | unreleased | +| In-Memory PDF Opening | Done | unreleased | +| Embedded Font Extraction | Done | unreleased | +| Vector Graphics Extraction | Done | unreleased | +| Scientific Paper Text Extraction | Planned | v0.8.0 | +| HTML to PDF | Planned | v0.9.0 | +| PDF/A Compliance | Planned | v0.9.0 | +| PDF Render to Image | Planned | v0.9.0 | +| SVG Import | Planned | v0.9.0 | +| Barcode / QR Code | Planned | v0.9.0 | ## Backlog diff --git a/document.go b/document.go index c9de7cd..283c245 100644 --- a/document.go +++ b/document.go @@ -313,6 +313,29 @@ func (d *Document) ExtractTextFromPage(pageNum int) (string, error) { return result, nil } +// ExtractTextElementsFromPage extracts positioned text elements from a specific page. +// +// pageNum is 1-based. Returns an error if pageNum is out of range. +// +// This is a convenience wrapper around Page.ExtractTextElements. +// +// Example: +// +// elements, err := doc.ExtractTextElementsFromPage(1) +// if err != nil { +// log.Fatal(err) +// } +// for _, e := range elements { +// fmt.Printf("%q at (%.1f, %.1f)\n", e.Text, e.X, e.Y) +// } +func (d *Document) ExtractTextElementsFromPage(pageNum int) ([]TextElement, error) { + if pageNum < 1 || pageNum > d.PageCount() { + return nil, fmt.Errorf("gxpdf: page %d out of range (1-%d)", pageNum, d.PageCount()) + } + page := d.Page(pageNum - 1) + return page.ExtractTextElements() +} + // ExtractTablesFromPage extracts tables from a specific page (1-based). // // Errors are logged via slog. For error handling, use Page.ExtractTablesWithOptions. diff --git a/gxpdf.go b/gxpdf.go index 2e862ec..1978592 100644 --- a/gxpdf.go +++ b/gxpdf.go @@ -52,6 +52,9 @@ import ( // Version is the current version of the gxpdf library. const Version = "0.1.0-alpha" +// pathFromBytes is the path sentinel reported by Documents opened from in-memory bytes. +const pathFromBytes = "" + // ErrPasswordRequired is returned when a password is needed to open an encrypted PDF. // Use OpenWithPassword to provide a password. var ErrPasswordRequired = security.ErrPasswordRequired @@ -150,3 +153,79 @@ func OpenWithPasswordAndContext(ctx context.Context, path, password string) (*Do path: path, }, nil } + +// OpenFromBytes opens a PDF document from an in-memory byte slice. +// +// This is equivalent to Open but reads from memory instead of the filesystem. +// It is useful when the PDF data has been received over the network, read from +// a database, or produced in-process without writing to disk. +// +// The returned Document must be closed after use (Close is a no-op for +// in-memory documents but should still be called for future compatibility). +// +// Example: +// +// data, err := os.ReadFile("document.pdf") +// if err != nil { +// log.Fatal(err) +// } +// doc, err := gxpdf.OpenFromBytes(data) +// if err != nil { +// log.Fatal(err) +// } +// defer doc.Close() +// +// fmt.Printf("Pages: %d\n", doc.PageCount()) +func OpenFromBytes(data []byte) (*Document, error) { + return OpenFromBytesWithContext(context.Background(), data) +} + +// OpenFromBytesWithContext opens a PDF document from an in-memory byte slice +// with a custom context. +// +// The context can be used for cancellation and timeouts during table or text +// extraction operations. +func OpenFromBytesWithContext(ctx context.Context, data []byte) (*Document, error) { + reader, err := parser.OpenPDFFromBytes(data) + if err != nil { + return nil, fmt.Errorf("gxpdf: failed to open PDF from bytes: %w", err) + } + + return &Document{ + reader: reader, + ctx: ctx, + path: pathFromBytes, + }, nil +} + +// OpenFromBytesWithPassword opens a password-protected PDF from an in-memory byte slice. +// +// For PDFs with an empty user password (permissions-only encryption), +// OpenFromBytes handles them transparently. +// +// Example: +// +// data, _ := os.ReadFile("encrypted.pdf") +// doc, err := gxpdf.OpenFromBytesWithPassword(data, "secret") +// if err != nil { +// log.Fatal(err) +// } +// defer doc.Close() +func OpenFromBytesWithPassword(data []byte, password string) (*Document, error) { + return OpenFromBytesWithPasswordAndContext(context.Background(), data, password) +} + +// OpenFromBytesWithPasswordAndContext opens a password-protected in-memory PDF +// with a custom context. +func OpenFromBytesWithPasswordAndContext(ctx context.Context, data []byte, password string) (*Document, error) { + reader, err := parser.OpenPDFFromBytesWithPassword(data, password) + if err != nil { + return nil, fmt.Errorf("gxpdf: failed to open encrypted PDF from bytes: %w", err) + } + + return &Document{ + reader: reader, + ctx: ctx, + path: pathFromBytes, + }, nil +} diff --git a/internal/parser/reader.go b/internal/parser/reader.go index 5f99031..20369f5 100644 --- a/internal/parser/reader.go +++ b/internal/parser/reader.go @@ -53,7 +53,14 @@ const maxXRefChainDepth = 100 // // Reference: PDF 1.7 specification, Section 7.5 (File Structure). type Reader struct { - file *os.File + // src is the underlying byte source. For file-based readers this wraps *os.File; + // for in-memory readers (OpenFromBytes) this is *bytes.Reader. + src io.ReadSeeker + // closer holds the closer for file-based readers; nil for in-memory readers. + closer io.Closer + // fileSize is the total byte length of the PDF data, cached at open time. + fileSize int64 + filename string version string xrefTable *XRefTable @@ -99,6 +106,19 @@ func NewReader(filename string) *Reader { } } +// NewReaderFromBytes creates a new PDF reader backed by an in-memory byte slice. +// +// The reader operates entirely in memory — no file I/O is performed. +// Call Open or openWithPassword to parse the document structure. +func NewReaderFromBytes(data []byte) *Reader { + return &Reader{ + src: bytes.NewReader(data), + fileSize: int64(len(data)), + objectCache: make(map[int]PdfObject), + objStmCache: make(map[int]map[int]PdfObject), + } +} + // Open opens the PDF file and parses its structure. // // For encrypted PDFs with an empty user password (the most common case for @@ -121,14 +141,25 @@ func (r *Reader) Open() error { return r.openWithPassword("") } -// openWithPassword opens the PDF file and initializes decryption with the given password. +// openWithPassword opens the PDF and initializes decryption with the given password. +// For file-based readers (src == nil) this opens the file and caches its size. +// For in-memory readers (src already set) this skips file I/O. func (r *Reader) openWithPassword(password string) error { - // Open file - file, err := os.Open(r.filename) - if err != nil { - return fmt.Errorf("failed to open file: %w", err) + if r.src == nil { + // File-based path: open the OS file and record its size. + file, err := os.Open(r.filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + fi, err := file.Stat() + if err != nil { + _ = file.Close() + return fmt.Errorf("failed to stat file: %w", err) + } + r.src = file + r.closer = file + r.fileSize = fi.Size() } - r.file = file // Read and validate header, get offset of leading whitespace version, headerOffset, err := r.readHeader() @@ -168,10 +199,12 @@ func (r *Reader) openWithPassword(password string) error { } // Close closes the PDF file and releases resources. +// For in-memory readers (created via NewReaderFromBytes), Close is a safe no-op. func (r *Reader) Close() error { - if r.file != nil { - err := r.file.Close() - r.file = nil + if r.closer != nil { + err := r.closer.Close() + r.closer = nil + r.src = nil return err } return nil @@ -209,13 +242,13 @@ const maxHeaderSearchSize = 1024 // Reference: PDF 1.7 specification, Section 7.5.1 (File Header) and Appendix H.3. func (r *Reader) readHeader() (version string, headerOffset int64, err error) { // Seek to start of file - if _, err := r.file.Seek(0, io.SeekStart); err != nil { + if _, err := r.src.Seek(0, io.SeekStart); err != nil { return "", 0, fmt.Errorf("failed to seek to start: %w", err) } // Read first 1024 bytes (PDF spec Appendix H.3) buf := make([]byte, maxHeaderSearchSize) - n, err := r.file.Read(buf) + n, err := r.src.Read(buf) if err != nil && err != io.EOF { return "", 0, fmt.Errorf("failed to read header: %w", err) } @@ -289,13 +322,7 @@ func (r *Reader) readHeader() (version string, headerOffset int64, err error) { // // Reference: PDF 1.7 specification, Section 7.5.5 (File Trailer). func (r *Reader) findStartXRef() (int64, error) { - // Get file size - fileInfo, err := r.file.Stat() - if err != nil { - return 0, fmt.Errorf("failed to stat file: %w", err) - } - - size := fileInfo.Size() + size := r.fileSize if size == 0 { return 0, fmt.Errorf("file is empty") } @@ -335,13 +362,13 @@ func (r *Reader) searchForStartXRef(fileSize, searchSize int64) (int64, bool, er searchSize = fileSize } - if _, err := r.file.Seek(seekPos, io.SeekStart); err != nil { + if _, err := r.src.Seek(seekPos, io.SeekStart); err != nil { return 0, false, fmt.Errorf("failed to seek to search region: %w", err) } // Read search region buf := make([]byte, searchSize) - n, err := io.ReadFull(r.file, buf) + n, err := io.ReadFull(r.src, buf) if err != nil && err != io.ErrUnexpectedEOF { return 0, false, fmt.Errorf("failed to read search region: %w", err) } @@ -477,19 +504,19 @@ func (r *Reader) parseSingleXRef(offset int64) (*XRefTable, *Dictionary, error) adjustedOffset := r.adjustOffset(offset) // Seek to XRef offset - if _, err := r.file.Seek(adjustedOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(adjustedOffset, io.SeekStart); err != nil { return nil, nil, fmt.Errorf("failed to seek to xref at offset %d: %w", offset, err) } // Peek at first few bytes to determine xref type peekBuf := make([]byte, 10) - n, err := r.file.Read(peekBuf) + n, err := r.src.Read(peekBuf) if err != nil { return nil, nil, fmt.Errorf("failed to peek at xref: %w", err) } // Seek back to start of xref - if _, err := r.file.Seek(adjustedOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(adjustedOffset, io.SeekStart); err != nil { return nil, nil, fmt.Errorf("failed to seek back to xref: %w", err) } @@ -510,7 +537,7 @@ func (r *Reader) parseSingleXRef(offset int64) (*XRefTable, *Dictionary, error) } // Parse traditional xref table - parser := NewParser(r.file) + parser := NewParser(r.src) xrefTable, err := parser.ParseXRef() if err != nil { return nil, nil, fmt.Errorf("failed to parse xref table: %w", err) @@ -528,16 +555,11 @@ func (r *Reader) parseSingleXRef(offset int64) (*XRefTable, *Dictionary, error) // // Reference: PDF 1.7 specification, Section 7.5.8. func (r *Reader) parseXRefStream(xrefOffset int64) (*XRefTable, error) { - // Create a parser to read the object header and dictionary - parser := NewParser(r.file) - - // Call the parser's ParseXRefStream, but we'll need to handle stream reading ourselves - // For now, let's parse just the object structure - xrefTable, err := parser.ParseXRefStreamWithFileAccess(r.file, xrefOffset) + parser := NewParser(r.src) + xrefTable, err := parser.ParseXRefStreamWithFileAccess(r.src, xrefOffset) if err != nil { return nil, err } - return xrefTable, nil } @@ -718,12 +740,16 @@ func (r *Reader) parseObjectAtOffset(offset int64) (*IndirectObject, error) { r.fileMu.Lock() defer r.fileMu.Unlock() + if r.src == nil { + return nil, fmt.Errorf("reader is closed") + } + adjustedOffset := r.adjustOffset(offset) - if _, err := r.file.Seek(adjustedOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(adjustedOffset, io.SeekStart); err != nil { return nil, fmt.Errorf("failed to seek to offset %d: %w", offset, err) } - parser := NewParser(r.file) + parser := NewParser(r.src) return parser.ParseIndirectObject() } @@ -767,11 +793,11 @@ func (r *Reader) scanDirection(startOffset int64, pattern []byte, maxSize int, f } buf := make([]byte, maxSize) - if _, err := r.file.Seek(readOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(readOffset, io.SeekStart); err != nil { return nil } - n, err := r.file.Read(buf) + n, err := r.src.Read(buf) if err != nil || n == 0 { return nil } @@ -784,11 +810,11 @@ func (r *Reader) scanDirection(startOffset int64, pattern []byte, maxSize int, f // Found it - seek to that position and parse foundOffset := readOffset + int64(idx) - if _, err := r.file.Seek(foundOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(foundOffset, io.SeekStart); err != nil { return nil } - parser := NewParser(r.file) + parser := NewParser(r.src) obj, err := parser.ParseIndirectObject() if err != nil { return nil @@ -852,13 +878,13 @@ func (r *Reader) getCompressedObject(objectNum int, entry *XRefEntry) (PdfObject // Seek to ObjStm (adjust for any leading whitespace before %PDF- header) r.fileMu.Lock() adjustedOffset := r.adjustOffset(objStmEntry.Offset) - if _, err := r.file.Seek(adjustedOffset, io.SeekStart); err != nil { + if _, err := r.src.Seek(adjustedOffset, io.SeekStart); err != nil { r.fileMu.Unlock() return nil, fmt.Errorf("failed to seek to ObjStm %d: %w", objStmNum, err) } // Parse ObjStm indirect object - parser := NewParser(r.file) + parser := NewParser(r.src) indirectObj, err := parser.ParseIndirectObject() r.fileMu.Unlock() @@ -1635,6 +1661,33 @@ func (r *Reader) decryptParsedObject(obj PdfObject, objNum, genNum int) PdfObjec } } +// OpenPDFFromBytes opens a PDF document from an in-memory byte slice. +// +// Equivalent to OpenPDF but reads from memory rather than the filesystem. +// The data slice is not modified and does not need to remain valid after Open +// returns, because bytes.NewReader retains its own reference. +func OpenPDFFromBytes(data []byte) (*Reader, error) { + return openFromBytesWithPassword(data, "") +} + +// OpenPDFFromBytesWithPassword opens an encrypted PDF from an in-memory byte slice +// using the given password. +// +// For PDFs with an empty user password (permissions-only encryption), +// use OpenPDFFromBytes — it handles empty passwords transparently. +func OpenPDFFromBytesWithPassword(data []byte, password string) (*Reader, error) { + return openFromBytesWithPassword(data, password) +} + +// openFromBytesWithPassword is the shared implementation for in-memory PDF opening. +func openFromBytesWithPassword(data []byte, password string) (*Reader, error) { + reader := NewReaderFromBytes(data) + if err := reader.openWithPassword(password); err != nil { + return nil, err + } + return reader, nil +} + // OpenPDFWithPassword is a convenience function that creates a Reader and opens // an encrypted PDF with the given password. // diff --git a/internal/parser/reader_test.go b/internal/parser/reader_test.go index cbff5db..73c1f3b 100644 --- a/internal/parser/reader_test.go +++ b/internal/parser/reader_test.go @@ -158,8 +158,8 @@ func TestReader_Close(t *testing.T) { err = reader.Close() assert.NoError(t, err) - // Verify file is closed (file handle should be nil) - assert.Nil(t, reader.file) + // Verify file is closed (closer and src should be nil after Close) + assert.Nil(t, reader.closer) // Closing again should not error err = reader.Close() diff --git a/page.go b/page.go index bcb82a4..58538dd 100644 --- a/page.go +++ b/page.go @@ -1,6 +1,8 @@ package gxpdf import ( + "fmt" + "github.com/coregx/gxpdf/internal/extractor" "github.com/coregx/gxpdf/internal/tabledetect" "github.com/coregx/gxpdf/logging" @@ -48,6 +50,48 @@ func (p *Page) ExtractText() string { return result } +// ExtractTextElements extracts positioned text elements from the page. +// +// Unlike ExtractText, which returns a plain string, ExtractTextElements +// returns each text run with its position, size, and font information. +// This is useful for layout analysis, text highlighting, and advanced +// document intelligence tasks. +// +// Coordinates follow the PDF convention: origin (0,0) is at the bottom-left +// of the page, X increases to the right, Y increases upward, in points +// (1 pt = 1/72 inch). +// +// Example: +// +// elements, err := page.ExtractTextElements() +// if err != nil { +// log.Fatal(err) +// } +// for _, e := range elements { +// fmt.Printf("%q at (%.1f, %.1f) size=%.1f\n", e.Text, e.X, e.Y, e.FontSize) +// } +func (p *Page) ExtractTextElements() ([]TextElement, error) { + textExtractor := extractor.NewTextExtractor(p.doc.reader) + internal, err := textExtractor.ExtractFromPage(p.index) + if err != nil { + return nil, fmt.Errorf("gxpdf: failed to extract text elements from page %d: %w", p.index, err) + } + + elements := make([]TextElement, len(internal)) + for i, e := range internal { + elements[i] = TextElement{ + Text: e.Text, + X: e.X, + Y: e.Y, + Width: e.Width, + Height: e.Height, + FontName: e.FontName, + FontSize: e.FontSize, + } + } + return elements, nil +} + // ExtractTables extracts all tables from this page. // // Errors are logged via slog. For error handling, use ExtractTablesWithOptions. diff --git a/text_element.go b/text_element.go new file mode 100644 index 0000000..2298130 --- /dev/null +++ b/text_element.go @@ -0,0 +1,34 @@ +package gxpdf + +// TextElement represents a single text run extracted from a PDF page, +// with full positional and font metadata. +// +// Coordinates follow the PDF coordinate system (ISO 32000-1, §8.3.2): +// - Origin (0, 0) is at the bottom-left corner of the page. +// - X increases to the right, Y increases upward. +// - All measurements are in points (1 pt = 1/72 inch). +// +// Use Page.ExtractTextElements or Document.ExtractTextElementsFromPage to +// obtain slices of TextElement. +type TextElement struct { + // Text is the actual string content of this run. + Text string + + // X is the left edge of the text run in points. + X float64 + + // Y is the bottom edge of the text run in points. + Y float64 + + // Width is the horizontal extent of the text run in points. + Width float64 + + // Height is the vertical extent of the text run in points. + Height float64 + + // FontName is the PDF internal font resource name (e.g. "/F1", "/Helvetica"). + FontName string + + // FontSize is the rendered font size in points. + FontSize float64 +} diff --git a/text_element_open_bytes_test.go b/text_element_open_bytes_test.go new file mode 100644 index 0000000..ca227eb --- /dev/null +++ b/text_element_open_bytes_test.go @@ -0,0 +1,420 @@ +package gxpdf + +import ( + "os" + "strings" + "testing" +) + +// ---------- TextElement struct ---------- + +func TestTextElement_Fields(t *testing.T) { + e := TextElement{ + Text: "Hello", + X: 10.5, + Y: 200.0, + Width: 50.0, + Height: 12.0, + FontName: "/F1", + FontSize: 12.0, + } + if e.Text != "Hello" { + t.Errorf("Text = %q, want %q", e.Text, "Hello") + } + if e.X != 10.5 { + t.Errorf("X = %v, want 10.5", e.X) + } + if e.FontSize != 12.0 { + t.Errorf("FontSize = %v, want 12.0", e.FontSize) + } +} + +// ---------- Page.ExtractTextElements ---------- + +func TestPage_ExtractTextElements_ReturnsElements(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + page := doc.Page(0) + if page == nil { + t.Fatal("Page(0) returned nil") + } + + elements, err := page.ExtractTextElements() + if err != nil { + t.Fatalf("ExtractTextElements() error = %v", err) + } + // minimal.pdf is known to have at least some text + t.Logf("ExtractTextElements() returned %d elements", len(elements)) +} + +func TestPage_ExtractTextElements_TextMatchesExtractText(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + page := doc.Page(0) + if page == nil { + t.Fatal("Page(0) returned nil") + } + + elements, err := page.ExtractTextElements() + if err != nil { + t.Fatalf("ExtractTextElements() error = %v", err) + } + + // Build concatenated text from elements and compare with ExtractText + var sb strings.Builder + for _, e := range elements { + sb.WriteString(e.Text) + sb.WriteString(" ") + } + fromElements := strings.TrimSpace(sb.String()) + fromExtractText := strings.TrimSpace(page.ExtractText()) + + if fromElements != fromExtractText { + t.Errorf("Text from ExtractTextElements %q does not match ExtractText %q", + fromElements, fromExtractText) + } +} + +func TestPage_ExtractTextElements_PositiveCoordinates(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + page := doc.Page(0) + elements, err := page.ExtractTextElements() + if err != nil { + t.Fatalf("ExtractTextElements() error = %v", err) + } + + for i, e := range elements { + if e.X < 0 { + t.Errorf("elements[%d].X = %v, want >= 0", i, e.X) + } + if e.Y < 0 { + t.Errorf("elements[%d].Y = %v, want >= 0", i, e.Y) + } + if e.Width < 0 { + t.Errorf("elements[%d].Width = %v, want >= 0", i, e.Width) + } + if e.Height < 0 { + t.Errorf("elements[%d].Height = %v, want >= 0", i, e.Height) + } + } +} + +func TestPage_ExtractTextElements_FontSizePositive(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + page := doc.Page(0) + elements, err := page.ExtractTextElements() + if err != nil { + t.Fatalf("ExtractTextElements() error = %v", err) + } + + for i, e := range elements { + if e.FontSize < 0 { + t.Errorf("elements[%d].FontSize = %v, want >= 0", i, e.FontSize) + } + } +} + +// ---------- Document.ExtractTextElementsFromPage ---------- + +func TestDocument_ExtractTextElementsFromPage_ValidPage(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + elements, err := doc.ExtractTextElementsFromPage(1) + if err != nil { + t.Fatalf("ExtractTextElementsFromPage(1) error = %v", err) + } + t.Logf("Page 1 has %d text elements", len(elements)) +} + +func TestDocument_ExtractTextElementsFromPage_InvalidPage_Zero(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + _, err = doc.ExtractTextElementsFromPage(0) + if err == nil { + t.Error("ExtractTextElementsFromPage(0) expected error, got nil") + } +} + +func TestDocument_ExtractTextElementsFromPage_InvalidPage_TooHigh(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + _, err = doc.ExtractTextElementsFromPage(9999) + if err == nil { + t.Error("ExtractTextElementsFromPage(9999) expected error, got nil") + } +} + +func TestDocument_ExtractTextElementsFromPage_MatchesPageExtractTextElements(t *testing.T) { + doc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot open %s: %v", minimalPDF, err) + } + defer doc.Close() + + // Via Document convenience method (1-based) + docElements, err := doc.ExtractTextElementsFromPage(1) + if err != nil { + t.Fatalf("ExtractTextElementsFromPage(1) error = %v", err) + } + + // Via Page method (0-based) + page := doc.Page(0) + pageElements, err := page.ExtractTextElements() + if err != nil { + t.Fatalf("page.ExtractTextElements() error = %v", err) + } + + if len(docElements) != len(pageElements) { + t.Errorf("element count mismatch: Document=%d, Page=%d", len(docElements), len(pageElements)) + } + + for i := 0; i < len(docElements) && i < len(pageElements); i++ { + if docElements[i].Text != pageElements[i].Text { + t.Errorf("elements[%d].Text: Document=%q, Page=%q", + i, docElements[i].Text, pageElements[i].Text) + } + if docElements[i].X != pageElements[i].X { + t.Errorf("elements[%d].X: Document=%v, Page=%v", + i, docElements[i].X, pageElements[i].X) + } + } +} + +// ---------- OpenFromBytes ---------- + +func TestOpenFromBytes_MinimalPDF(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + doc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + defer doc.Close() + + if doc == nil { + t.Fatal("OpenFromBytes returned nil document") + } +} + +func TestOpenFromBytes_SameContentAsOpen(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + // Open via file + fileDoc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot Open %s: %v", minimalPDF, err) + } + defer fileDoc.Close() + + // Open via bytes + bytesDoc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + defer bytesDoc.Close() + + // Page count must match + if fileDoc.PageCount() != bytesDoc.PageCount() { + t.Errorf("PageCount: file=%d, bytes=%d", fileDoc.PageCount(), bytesDoc.PageCount()) + } + + // Version must match + if fileDoc.Version() != bytesDoc.Version() { + t.Errorf("Version: file=%q, bytes=%q", fileDoc.Version(), bytesDoc.Version()) + } +} + +func TestOpenFromBytes_TextMatchesOpen(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + fileDoc, err := Open(minimalPDF) + if err != nil { + t.Skipf("cannot Open %s: %v", minimalPDF, err) + } + defer fileDoc.Close() + + bytesDoc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + defer bytesDoc.Close() + + // Compare text extraction on each page + for i := 0; i < fileDoc.PageCount(); i++ { + fileText := fileDoc.Page(i).ExtractText() + bytesText := bytesDoc.Page(i).ExtractText() + if fileText != bytesText { + t.Errorf("page %d text mismatch:\n file=%q\n bytes=%q", i, fileText, bytesText) + } + } +} + +func TestOpenFromBytes_EmptySlice(t *testing.T) { + _, err := OpenFromBytes([]byte{}) + if err == nil { + t.Error("OpenFromBytes(empty) expected error, got nil") + } +} + +func TestOpenFromBytes_NotAPDF(t *testing.T) { + _, err := OpenFromBytes([]byte("this is not a PDF document")) + if err == nil { + t.Error("OpenFromBytes(invalid data) expected error, got nil") + } +} + +func TestOpenFromBytes_PathIsBytes(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + doc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + defer doc.Close() + + // Path sentinel for in-memory documents + if doc.Path() != pathFromBytes { + t.Errorf("doc.Path() = %q, want %q", doc.Path(), pathFromBytes) + } +} + +func TestOpenFromBytes_CloseIsNoOp(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + doc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + + // Close multiple times must not panic or error + if err := doc.Close(); err != nil { + t.Errorf("first Close() error = %v", err) + } + if err := doc.Close(); err != nil { + t.Errorf("second Close() error = %v", err) + } +} + +func TestOpenFromBytes_MultiPage(t *testing.T) { + data, err := os.ReadFile(multipagePDF) + if err != nil { + t.Skipf("cannot read %s: %v", multipagePDF, err) + } + + doc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() multipage error = %v", err) + } + defer doc.Close() + + if doc.PageCount() < 2 { + t.Skipf("multipage PDF has only %d pages", doc.PageCount()) + } + t.Logf("OpenFromBytes multipage: %d pages", doc.PageCount()) +} + +// ---------- OpenFromBytesWithPassword ---------- + +func TestOpenFromBytesWithPassword_NonEncrypted(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + // A non-encrypted PDF opened with any password should succeed + // (the password is ignored for non-encrypted PDFs) + doc, err := OpenFromBytesWithPassword(data, "anypassword") + if err != nil { + t.Fatalf("OpenFromBytesWithPassword(non-encrypted) error = %v", err) + } + defer doc.Close() + + if doc.PageCount() == 0 { + t.Error("expected at least 1 page") + } +} + +func TestOpenFromBytesWithPassword_EmptyPassword(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + doc, err := OpenFromBytesWithPassword(data, "") + if err != nil { + t.Fatalf("OpenFromBytesWithPassword(empty password) error = %v", err) + } + defer doc.Close() +} + +func TestOpenFromBytesWithPassword_InvalidData(t *testing.T) { + _, err := OpenFromBytesWithPassword([]byte("not a pdf"), "password") + if err == nil { + t.Error("OpenFromBytesWithPassword(invalid) expected error, got nil") + } +} + +// ---------- OpenFromBytesWithContext ---------- + +func TestOpenFromBytesWithContext_Success(t *testing.T) { + data, err := os.ReadFile(minimalPDF) + if err != nil { + t.Skipf("cannot read %s: %v", minimalPDF, err) + } + + // Uses background context (same as OpenFromBytes internally) + doc, err := OpenFromBytes(data) + if err != nil { + t.Fatalf("OpenFromBytes() error = %v", err) + } + defer doc.Close() + + if doc.PageCount() == 0 { + t.Error("expected at least 1 page") + } +}