From 99fe2a0f79fd431db22f4b1bc5ae440588239087 Mon Sep 17 00:00:00 2001 From: Harshaneel Gokhale Date: Thu, 6 Aug 2026 00:37:30 -0700 Subject: [PATCH] fix: Render standard-font PDFs in the proxy image, add a Gemini PDF example Alpine's poppler-utils ships without the Base-14 PDF fonts (Helvetica, Times, Courier), so the proxy image rasterized those pages blank and the model received an empty image. Adds ttf-liberation, which supplies metric-compatible substitutes. The image grows from about 41 MB to 46 MB. The existing proxy-image PDF test only checked that a valid PNG came back, so a blank page passed. It now requires the rendered page to contain dark pixels, which is what fails when the fonts are missing. Adds examples/go/gemini-pdf: it sends an embedded fake invoice through the Gemini SDK and extracts the fields as structured JSON. Verified end to end against a real llama.cpp with the vision projector; it returns the invoice's actual values. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ Dockerfile | 4 +- README.md | 2 +- examples/README.md | 4 +- examples/go/gemini-pdf/main.go | 90 ++++++++++++++++++++++++++++++ examples/go/gemini-pdf/sample.pdf | Bin 0 -> 733 bytes integration/proxy_image_test.go | 22 +++++++- 7 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 examples/go/gemini-pdf/main.go create mode 100644 examples/go/gemini-pdf/sample.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md index 23394d8..97800fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Releases are cut by pushing a `v*` tag, which publishes the images to Docker Hub. Entries before v0.4.0 were reconstructed from git history. +## Unreleased + +### Fixed + +- The `proxy` image now renders PDFs that use the standard PDF fonts (Helvetica, Times, Courier). Alpine's `poppler-utils` ships without them, so those pages previously rasterized blank and the model saw nothing. Adds `font-liberation` (about 5 MB, so the image is now ~46 MB). + ## v0.4.1 (2026-08-06) ### Changed diff --git a/Dockerfile b/Dockerfile index b0a69d1..053bf7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ COPY . . RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/localaik ./cmd/localaik FROM alpine:3@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS proxy -RUN apk add --no-cache ca-certificates poppler-utils tini +# font-liberation gives poppler the Base-14 fonts (Helvetica, Times, Courier); +# without it, standard-font PDFs render as blank pages. +RUN apk add --no-cache ca-certificates poppler-utils font-liberation tini COPY --from=proxy-builder /out/localaik /usr/local/bin/localaik ENV PORT=8090 # No inference engine here, so the loopback default could never work. diff --git a/README.md b/README.md index 9469f4a..cfcc201 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ client := anthropic.NewClient( | --------------------- | ------------------ | ---------- | | `latest`, `gemma3-4b` | Gemma 3 4B Q4_K_M | ~3 GB | | `gemma3-12b` | Gemma 3 12B Q4_K_M | ~7 GB | -| `proxy` | none (you supply) | ~41 MB | +| `proxy` | none (you supply) | ~46 MB | Version-pinned tags follow the pattern `v0.1.1-gemma3-4b`, `v0.1.1-gemma3-12b`, diff --git a/examples/README.md b/examples/README.md index 6cfbb8c..17faff6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ Small, runnable samples that talk to **localaik** on `http://localhost:8090`. Us docker run -d -p 8090:8090 gokhalh/localaik ``` - Or from the repo root: `make docker-up` (defaults to port `18090` — set `PORT=8090` if you want the examples unchanged). + Or from the repo root: `make docker-up` (defaults to port `18090`; set `PORT=8090` if you want the examples unchanged). 2. Wait until the model is loaded (`GET /health` returns 200). The first start can take a while. @@ -31,6 +31,8 @@ Small, runnable samples that talk to **localaik** on `http://localhost:8090`. Us | **JavaScript** | [javascript/gemini](javascript/gemini/index.mjs) | [javascript/openai](javascript/openai/index.mjs) | not yet | [javascript/gemini-structured](javascript/gemini-structured/index.mjs) | | **Java** | [java/gemini](java/gemini/Gemini.java) | [java/openai](java/openai/OpenAI.java) | not yet | [java/gemini-structured](java/gemini-structured/GeminiStructured.java) | +**PDF parsing:** [go/gemini-pdf](go/gemini-pdf/main.go) sends a fake invoice PDF through Gemini and extracts its fields as structured JSON. It ships an embedded sample, so `go run main.go` works with no setup; pass a path to use your own PDF. + ## Conventions - **Base URL:** `http://localhost:8090` for Gemini-style calls; OpenAI clients use `http://localhost:8090/v1`; Anthropic clients use `http://localhost:8090` (their SDKs append `v1/` themselves). diff --git a/examples/go/gemini-pdf/main.go b/examples/go/gemini-pdf/main.go new file mode 100644 index 0000000..f5f70af --- /dev/null +++ b/examples/go/gemini-pdf/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "log" + "os" + + "google.golang.org/genai" +) + +// A small fake invoice so the example runs with no setup. Pass a path argument +// to use your own PDF instead. +// +//go:embed sample.pdf +var samplePDF []byte + +type invoice struct { + InvoiceNumber string `json:"invoice_number"` + Vendor string `json:"vendor"` + AmountDue string `json:"amount_due"` + DueDate string `json:"due_date"` +} + +func main() { + ctx := context.Background() + + pdfBytes := samplePDF + if len(os.Args) > 1 { + data, err := os.ReadFile(os.Args[1]) + if err != nil { + log.Fatal(err) + } + pdfBytes = data + } + + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: "test", + Backend: genai.BackendGeminiAPI, + HTTPOptions: genai.HTTPOptions{ + BaseURL: "http://localhost:8090", + }, + }) + if err != nil { + log.Fatal(err) + } + + // A schema plus temperature 0 make the model read the fields off the page + // rather than inventing plausible-looking ones. + config := &genai.GenerateContentConfig{ + Temperature: genai.Ptr[float32](0), + ResponseMIMEType: "application/json", + ResponseSchema: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "invoice_number": {Type: genai.TypeString}, + "vendor": {Type: genai.TypeString}, + "amount_due": {Type: genai.TypeString}, + "due_date": {Type: genai.TypeString}, + }, + Required: []string{"invoice_number", "vendor", "amount_due", "due_date"}, + }, + } + + resp, err := client.Models.GenerateContent(ctx, + "localaik", + []*genai.Content{{ + Parts: []*genai.Part{ + {Text: "Extract the invoice fields from this document."}, + genai.NewPartFromBytes(pdfBytes, "application/pdf"), + }, + }}, + config, + ) + if err != nil { + log.Fatal(err) + } + + var inv invoice + if err := json.Unmarshal([]byte(resp.Text()), &inv); err != nil { + log.Fatalf("response was not valid JSON: %v\nraw: %s", err, resp.Text()) + } + + fmt.Printf("invoice number: %s\n", inv.InvoiceNumber) + fmt.Printf("vendor: %s\n", inv.Vendor) + fmt.Printf("amount due: %s\n", inv.AmountDue) + fmt.Printf("due date: %s\n", inv.DueDate) +} diff --git a/examples/go/gemini-pdf/sample.pdf b/examples/go/gemini-pdf/sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c41655375c1251a4ae14b4941b1551a9883f252b GIT binary patch literal 733 zcmZXSO;5r=5QgvjE9M}HCYJrMEnp0Z2t=X+frg9mu)u&d&^7JWpugVPtq9_t+IOD$ zp4zzE4`e-L5(Jp}8@ssyo_xL|@H=hw#;k#l^%@IM1WcgaW|%D<=>GpYR2+Xumjygu zIsAp9m@=~#=Ur>Rr z^1h)6{zF3Kfp(ixA7yuL`|OXHt?etwX3GJE%~5YzCt4?xT28vvjBy9$w?9q45aMckI=8;gC=#qz>y7&C8wU-&!vFvP literal 0 HcmV?d00001 diff --git a/integration/proxy_image_test.go b/integration/proxy_image_test.go index d8e8e3a..9818bbe 100644 --- a/integration/proxy_image_test.go +++ b/integration/proxy_image_test.go @@ -170,12 +170,28 @@ func TestProxyImageRoundTripsAllProtocols(t *testing.T) { if err != nil { t.Fatalf("rendered page was not valid base64: %v", err) } - config, err := png.DecodeConfig(bytes.NewReader(page)) + img, err := png.Decode(bytes.NewReader(page)) if err != nil { t.Fatalf("rendered page was not a valid PNG: %v", err) } - if config.Width == 0 || config.Height == 0 { - t.Fatalf("rendered page is %dx%d", config.Width, config.Height) + bounds := img.Bounds() + if bounds.Dx() == 0 || bounds.Dy() == 0 { + t.Fatalf("rendered page is %dx%d", bounds.Dx(), bounds.Dy()) + } + + // A page missing its fonts renders blank, so require some ink. This is + // what catches the proxy image shipping without the PDF base fonts. + ink := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := img.At(x, y).RGBA() + if r < 0x8000 && g < 0x8000 && b < 0x8000 { + ink++ + } + } + } + if ink == 0 { + t.Fatal("rendered page has no dark pixels; the proxy image is likely missing PDF fonts (font-liberation)") } })