Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
| `csv` | CSV module and ``CSV`` namespace helpers for Ferret. | [modules/csv/README.md](./modules/csv/README.md) |
| `db/postgres` | Postgres database handles under `DB::POSTGRES` for Ferret. | [modules/db/postgres/README.md](./modules/db/postgres/README.md) |
| `db/sqlite` | SQLite database handles under `DB::SQLITE` for Ferret. | [modules/db/sqlite/README.md](./modules/db/sqlite/README.md) |
| `document/pdf` | Read-only PDF document handles under `DOCUMENT::PDF` for Ferret. | [modules/document/pdf/README.md](./modules/document/pdf/README.md) |
| `document/xlsx` | Excel-compatible `.xlsx` workbook handles under `DOCUMENT::XLSX` for Ferret. | [modules/document/xlsx/README.md](./modules/document/xlsx/README.md) |
| `net/rest` | REST-style HTTP API clients under `NET::REST` for Ferret. | [modules/net/rest/README.md](./modules/net/rest/README.md) |
| `security/jwt` | JWT token signing, verification, and inspection helpers under `SECURITY::JWT` for Ferret. | [modules/security/jwt/README.md](./modules/security/jwt/README.md) |
Expand Down
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use (
./modules/csv
./modules/db/postgres
./modules/db/sqlite
./modules/document/pdf
./modules/document/xlsx
./modules/net/rest
./modules/security/jwt
Expand Down
129 changes: 129 additions & 0 deletions modules/document/pdf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# DOCUMENT::PDF Module

`github.com/MontFerret/contrib/modules/document/pdf` registers read-only PDF helpers under the `DOCUMENT::PDF` namespace.

The module opens PDFs through Ferret's filesystem abstraction, exposes lazy document and page handles, extracts best-effort text, reports basic page dimensions, and returns low-level positioned text fragments. `DOCUMENT::PDF::OPEN` is the only namespace function; document and page data are read through host-value properties.

The implementation uses `github.com/ledongthuc/pdf` internally, but that dependency is hidden behind module-owned core types so the public FQL API can remain stable if the parser is replaced later.

Out of scope for this version: PDF creation or modification, page insertion or deletion, form filling, annotation editing, digital signatures, password management, OCR, table recognition, paragraph or heading detection, metadata extraction, link extraction, image extraction, rendering, PDF-to-HTML conversion, remote HTTP URLs, and aliases under `PDF::`.

## Install

```sh
go get github.com/MontFerret/contrib/modules/document/pdf
```

## Register The Module

```go
package main

import (
"github.com/MontFerret/ferret/v2"

pdfmodule "github.com/MontFerret/contrib/modules/document/pdf"
)

func main() {
engine, err := ferret.New(
ferret.WithFSRoot("./documents"),
ferret.WithModules(pdfmodule.New()),
)
if err != nil {
panic(err)
}

_ = engine
}
```

`DOCUMENT::PDF::OPEN` uses Ferret's filesystem from the execution context. Configure `ferret.WithFSRoot` in the host application and use paths relative to that root. The module does not open unrestricted host paths directly.

When the filesystem source cannot provide random access, the module buffers the PDF in memory and passes a `bytes.Reader` to the parser. The default buffer limit is 64 MiB and can be configured at registration time:

```go
ferret.WithModules(pdfmodule.New(pdfmodule.WithMaxBufferSize(128 * 1024 * 1024)))
```

## API Reference

| Function | Signature | Returns | Notes |
| --- | --- | --- | --- |
| `DOCUMENT::PDF::OPEN` | `OPEN(path)` | Document handle | Opens an existing PDF through Ferret's filesystem. |

| Document property | Returns | Notes |
| --- | --- | --- |
| `document.pageCount` | `Int` | Returns the document page count. |
| `document.pages` | Page collection | Lazy iterable and zero-indexed page collection. |

| Page collection behavior | Returns | Notes |
| --- | --- | --- |
| `document.pages[0]` | Page handle or `NONE` | Uses normal zero-based FQL indexing. |
| `LENGTH(document.pages)` | `Int` | Returns the number of pages. |
| `FOR page IN document.pages` | Page handles | Creates page values lazily while iterating. |

| Page property | Returns | Notes |
| --- | --- | --- |
| `page.number` | `Int` | One-based page number. |
| `page.width` | `Float` | Page width in PDF points. |
| `page.height` | `Float` | Page height in PDF points. |
| `page.rotation` | `Int` | Normalized page rotation. |
| `page.text` | `String` | Extracts best-effort text when accessed. |
| `page.blocks` | `Array<Object>` | Extracts low-level positioned text fragments when accessed. |

## Examples

```fql
LET document = DOCUMENT::PDF::OPEN("./report.pdf")
RETURN {
pageCount: document.pageCount,
firstText: document.pages[0].text
}
```

```fql
LET document = DOCUMENT::PDF::OPEN("./report.pdf")
RETURN (
FOR page IN document.pages
RETURN {
number: page.number,
width: page.width,
height: page.height,
rotation: page.rotation,
text: page.text,
blocks: page.blocks
}
)
```

```fql
LET document = DOCUMENT::PDF::OPEN("./report.pdf")
RETURN {
count: LENGTH(document.pages),
firstBlocks: document.pages[0].blocks,
outOfRange: document.pages[999]
}
```

## Page And Coordinate Conventions

`document.pages` is lazy. Reading the collection or iterating page values does not extract text or positioned blocks. `page.text` and `page.blocks` perform extraction when those properties are accessed.

Collection indexes are zero-based, so `document.pages[0]` returns the first page. `page.number` remains one-based for PDF-domain display and consistency.

Page dimensions and text bounds are in PDF points. For page dimensions, the module prefers a valid crop box and falls back to the media box. Positioned text coordinates use the coordinate system returned by `github.com/ledongthuc/pdf`: X increases left to right, Y increases bottom to top, and the origin is bottom-left. The module does not convert coordinates.

`page.blocks` returns positioned text entries from the parser, not semantic paragraphs, headings, table cells, or layout regions. Empty and whitespace-only entries are omitted.

## Text Extraction Limitations

Text extraction is best-effort. Reading order can be wrong for multi-column or heavily positioned documents, custom font encodings can produce missing or incorrect characters, and malformed or uncommon PDFs can fail to parse. Scanned pages without embedded text usually return an empty string. OCR is not performed.

Password-protected and encrypted PDFs may not be supported reliably by the underlying parser. Metadata and link APIs are planned follow-up functionality and are not registered as stubs in this version.

## Lifecycle And Concurrency

Document handles retain the source reader or backing buffer while open. Page handles retain a reference to their parent document and become invalid after the document is closed. Document cleanup is safe and idempotent.

Document, page collection, and page operations guard shared open/closed state and are safe for ordinary concurrent reads. Cancellation is cooperative between major operations, including open, page extraction, and positioned-text iteration.
229 changes: 229 additions & 0 deletions modules/document/pdf/core/capability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package core

import (
"errors"
"io"
"strings"
"testing"

"github.com/MontFerret/ferret/v2/pkg/runtime"
)

func TestDocumentAndPageCapabilities(t *testing.T) {
t.Parallel()

ctx, root := testFSContext(t, false)
writePDFForTest(t, root, "capabilities.pdf", []pdfTestPage{
{Text: "Alpha", Width: 612, Height: 792, Rotation: 90},
{Text: "Beta", Width: 400, Height: 500, X: 36, Y: 420, FontSize: 18},
})

document, err := Open(ctx, "capabilities.pdf")
if err != nil {
t.Fatalf("unexpected open error: %v", err)
}
t.Cleanup(func() { _ = document.Close() })

countValue, err := document.Get(ctx, runtime.NewString("pageCount"))
if err != nil {
t.Fatalf("unexpected pageCount property error: %v", err)
}
if countValue != runtime.NewInt(2) {
t.Fatalf("pageCount = %v, want 2", countValue)
}

pagesValue, err := document.Get(ctx, runtime.NewString("pages"))
if err != nil {
t.Fatalf("unexpected pages property error: %v", err)
}
pages, ok := pagesValue.(*PageCollection)
if !ok {
t.Fatalf("expected PageCollection, got %T", pagesValue)
}
if pages.String() != "<document.pdf.pages>" || pages.Hash() == 0 || pages.Copy() != pages {
t.Fatal("expected page collection to expose stable runtime value identity")
}

length, err := pages.Length(ctx)
if err != nil {
t.Fatalf("unexpected length error: %v", err)
}
if length != runtime.NewInt(2) {
t.Fatalf("length = %v, want 2", length)
}

firstValue, err := pages.At(ctx, runtime.ZeroInt)
if err != nil {
t.Fatalf("unexpected first page access error: %v", err)
}
first, ok := firstValue.(*Page)
if !ok {
t.Fatalf("expected first page, got %T", firstValue)
}
if first.Number() != 1 {
t.Fatalf("first page number = %d, want 1", first.Number())
}

secondValue, found, err := pages.LookupAt(ctx, runtime.NewInt(1))
if err != nil {
t.Fatalf("unexpected second page lookup error: %v", err)
}
if !found {
t.Fatal("expected second page lookup to be found")
}
second := secondValue.(*Page)
if second.Number() != 2 {
t.Fatalf("second page number = %d, want 2", second.Number())
}

if value, err := pages.At(ctx, runtime.NewInt(-1)); err != nil || value != runtime.None {
t.Fatalf("negative index = %v, %v; want NONE, nil", value, err)
}
if value, found, err := pages.LookupAt(ctx, runtime.NewInt(99)); err != nil || found || value != runtime.None {
t.Fatalf("out-of-range lookup = %v, %v, %v; want NONE, false, nil", value, found, err)
}

number, err := first.Get(ctx, runtime.NewString("number"))
if err != nil {
t.Fatalf("unexpected number property error: %v", err)
}
if number != runtime.NewInt(1) {
t.Fatalf("number = %v, want 1", number)
}
width, err := first.Get(ctx, runtime.NewString("width"))
if err != nil {
t.Fatalf("unexpected width property error: %v", err)
}
if width != runtime.NewFloat(612) {
t.Fatalf("width = %v, want 612", width)
}
height, err := first.Get(ctx, runtime.NewString("height"))
if err != nil {
t.Fatalf("unexpected height property error: %v", err)
}
if height != runtime.NewFloat(792) {
t.Fatalf("height = %v, want 792", height)
}
rotation, err := first.Get(ctx, runtime.NewString("rotation"))
if err != nil {
t.Fatalf("unexpected rotation property error: %v", err)
}
if rotation != runtime.NewInt(90) {
t.Fatalf("rotation = %v, want 90", rotation)
}

text, err := first.Get(ctx, runtime.NewString("text"))
if err != nil {
t.Fatalf("unexpected text property error: %v", err)
}
if !strings.Contains(text.String(), "Alpha") {
t.Fatalf("text property = %q, want fixture text", text.String())
}

blocks, err := first.Get(ctx, runtime.NewString("blocks"))
if err != nil {
t.Fatalf("unexpected blocks property error: %v", err)
}
list, ok := blocks.(runtime.List)
if !ok {
t.Fatalf("expected blocks list, got %T", blocks)
}
if length, err := list.Length(ctx); err != nil || length == 0 {
t.Fatalf("blocks length = %v, %v; want non-zero", length, err)
}

if value, err := document.Get(ctx, runtime.NewString("missing")); err != nil || value != runtime.None {
t.Fatalf("unknown document key = %v, %v; want NONE, nil", value, err)
}
if value, err := first.Get(ctx, runtime.NewString("missing")); err != nil || value != runtime.None {
t.Fatalf("unknown page key = %v, %v; want NONE, nil", value, err)
}
if value, err := document.Get(ctx, runtime.None); err != nil || value != runtime.None {
t.Fatalf("empty document key = %v, %v; want NONE, nil", value, err)
}
if value, err := first.Get(ctx, runtime.None); err != nil || value != runtime.None {
t.Fatalf("empty page key = %v, %v; want NONE, nil", value, err)
}
}

func TestPageCollectionIteratesLazily(t *testing.T) {
t.Parallel()

ctx, root := testFSContext(t, false)
writePDFForTest(t, root, "iter.pdf", []pdfTestPage{
{Text: "One"},
{Text: "Two"},
})

document, err := Open(ctx, "iter.pdf")
if err != nil {
t.Fatalf("unexpected open error: %v", err)
}
t.Cleanup(func() { _ = document.Close() })

pages := NewPageCollection(document)
iter, err := pages.Iterate(ctx)
if err != nil {
t.Fatalf("unexpected iterate error: %v", err)
}

first, key, err := iter.Next(ctx)
if err != nil {
t.Fatalf("unexpected first next error: %v", err)
}
if key != runtime.ZeroInt || first.(*Page).Number() != 1 {
t.Fatalf("unexpected first iteration result: value=%v key=%v", first, key)
}

second, key, err := iter.Next(ctx)
if err != nil {
t.Fatalf("unexpected second next error: %v", err)
}
if key != runtime.NewInt(1) || second.(*Page).Number() != 2 {
t.Fatalf("unexpected second iteration result: value=%v key=%v", second, key)
}

if value, key, err := iter.Next(ctx); !errors.Is(err, io.EOF) || value != runtime.None || key != runtime.None {
t.Fatalf("final next = %v, %v, %v; want NONE, NONE, EOF", value, key, err)
}
}

func TestCapabilitiesFailAfterDocumentClose(t *testing.T) {
t.Parallel()

ctx, root := testFSContext(t, false)
writePDFForTest(t, root, "closed.pdf", []pdfTestPage{{Text: "Closed"}})

document, err := Open(ctx, "closed.pdf")
if err != nil {
t.Fatalf("unexpected open error: %v", err)
}
page, err := document.Page(1)
if err != nil {
t.Fatalf("unexpected page error: %v", err)
}
pages := NewPageCollection(document)

if err := document.Close(); err != nil {
t.Fatalf("unexpected close error: %v", err)
}

if _, err := document.Get(ctx, runtime.NewString("pageCount")); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed document property error, got %v", err)
}
if _, err := document.Get(ctx, runtime.NewString("pages")); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed pages property error, got %v", err)
}
if _, err := page.Get(ctx, runtime.NewString("number")); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed page property error, got %v", err)
}
if _, err := pages.Length(ctx); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed page collection length error, got %v", err)
}
if _, err := pages.At(ctx, runtime.ZeroInt); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed page collection index error, got %v", err)
}
if _, err := pages.Iterate(ctx); err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed page collection iteration error, got %v", err)
}
}
3 changes: 3 additions & 0 deletions modules/document/pdf/core/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package core contains the DOCUMENT::PDF implementation and hides the
// underlying PDF parsing library from Ferret-facing code.
package core
Loading
Loading