P5a plan: PDF documents — seven Ornith tasks with acceptance tests first
docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables, 04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI, skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted fixtures. Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard; TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and writeTimeout 120→240 s for the PDF path's worst case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@
|
|||||||
/sift
|
/sift
|
||||||
/tests/testdata/bakeoff-out/
|
/tests/testdata/bakeoff-out/
|
||||||
/tests/testdata/bakeoff-raw/
|
/tests/testdata/bakeoff-raw/
|
||||||
|
.state/
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
sift is the fleet's fetch and search service for LLM agents: it fetches a URL through an SSRF
|
||||||
|
guard, cleans the result (HTML → Markdown, JSON, text, and from P5a PDF), optionally digests it
|
||||||
|
with a small model, and serves Hermes agents through a Firecrawl-compatible API. It is written in
|
||||||
|
Go and runs on orion under a hardened systemd unit. You are implementing it one task at a time.
|
||||||
|
|
||||||
|
## How you work
|
||||||
|
|
||||||
|
1. The owner gives you one task file, `docs/plans/<plan>/NN-name.md`. Read it fully. Do that task
|
||||||
|
and nothing else. Do not start the next task.
|
||||||
|
2. Do the steps in order. Where a step shows a command and its expected output, run it and compare.
|
||||||
|
3. Do not read other task files, `docs/PLAN.md` or other phase docs unless the task tells you to.
|
||||||
|
The task file quotes what you need.
|
||||||
|
4. If something in the task is impossible, contradictory, or fails twice in the same way, **stop**.
|
||||||
|
Do not improvise, do not change a test, do not weaken a check. Add your row to
|
||||||
|
`docs/implementer-log.md` with status `stopped`, say what you tried and what happened, commit
|
||||||
|
only that file, and tell the owner.
|
||||||
|
5. Work only from files inside this repository. Never read another checkout, and never search the
|
||||||
|
file system for code. If you are stuck, stop as in point 4.
|
||||||
|
|
||||||
|
## Files you must never edit
|
||||||
|
|
||||||
|
- `docs/plans/`, `docs/PLAN.md`, `docs/P*.md`, `AGENTS.md`, `tools/run-plan.sh`
|
||||||
|
- `internal/guard/` and `internal/auth/` — the SSRF guard and the key store are the security
|
||||||
|
boundary; no task changes them.
|
||||||
|
- `go.mod`, `go.sum`, `vendor/` — every dependency is already vendored. Never add a module and
|
||||||
|
never run `go get` or `go mod tidy`/`vendor`; the build is offline.
|
||||||
|
- `nix/` and `flake.nix`, unless the task names the exact change.
|
||||||
|
- Anything a task told you to copy from `docs/plans/**/_files/` — tests, fixtures,
|
||||||
|
`internal/pdftest` — in this plan **or any earlier one**. If a copied test fails, your code is
|
||||||
|
wrong. If a copied test can no longer be right because the task changes what it tested, that
|
||||||
|
is the owner's error: stop and report it.
|
||||||
|
|
||||||
|
## Code rules
|
||||||
|
|
||||||
|
- Go 1.26 (`go.mod`). Standard library plus what is already in `vendor/`.
|
||||||
|
- No **new** source file over 400 lines. Do not grow `internal/api/api.go`: new API code goes in
|
||||||
|
the new file the task names.
|
||||||
|
- Library code (`internal/...`) never panics on input: no `panic`, no indexing that can go out of
|
||||||
|
range on data from a file, a request, a PDF or a peer. Check lengths. `main` may exit with a
|
||||||
|
message.
|
||||||
|
- Errors are values: return them, wrap with `fmt.Errorf("...: %w", err)`. A check that cannot do
|
||||||
|
its job fails; it does not return "ok".
|
||||||
|
- **Never log, print or store a request or response body, a document's text or its title.** Log
|
||||||
|
lines carry caller, host, sizes, counts, durations and outcomes. For a restricted caller
|
||||||
|
(`PermitRestricted`) use the existing helpers (`logHost`, `restrictedLog`, `logErr`): they
|
||||||
|
already blank what must not be written.
|
||||||
|
- Anything read from the network, a child process or a file is bounded (`io.LimitReader`,
|
||||||
|
deadlines).
|
||||||
|
- Exported names, field names and JSON tags are exactly as the task gives them; the tests compile
|
||||||
|
against them.
|
||||||
|
- Comments say why, not what. `gofmt` decides layout.
|
||||||
|
|
||||||
|
## Lessons from earlier work (crossbar, same owner, same process)
|
||||||
|
|
||||||
|
- A type assertion on a value from outside your package uses the two-value form and handles
|
||||||
|
`false`.
|
||||||
|
- When a rule says "every" or "everywhere", finish by listing each place it applies and checking
|
||||||
|
them one by one.
|
||||||
|
- Never end a turn by describing what you are about to do. Do it, then report.
|
||||||
|
- Work only inside this repository. Scratch programs under `/tmp` or anywhere else are refused by
|
||||||
|
the sandbox, and **a refused tool call is not a reason to end the turn**: write the experiment
|
||||||
|
as a `_test.go` file inside the repository (delete it before committing), or reason it out.
|
||||||
|
- Never change when production code releases, flushes or records something just to make a given
|
||||||
|
test's timing pass. If a given test seems to check a value before the code could settle it,
|
||||||
|
that is the owner's bug: stop and report it.
|
||||||
|
|
||||||
|
## The gate
|
||||||
|
|
||||||
|
`make gate` must pass before a task is done:
|
||||||
|
|
||||||
|
```
|
||||||
|
make gate # gofmt check, go vet, go test -race ./..., staticcheck if installed
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs offline against `vendor/`. Run `gofmt -w` on the files you touched first. `go test
|
||||||
|
./internal/<pkg>/` runs one package.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- Work on the branch the task names. One task is one commit.
|
||||||
|
- Stage only the paths the task lists: `git add <path> ...`. Never `git add -A` or `git add .`.
|
||||||
|
- Never push, amend, rebase, reset, or switch to another branch.
|
||||||
|
- Commit message: the subject line the task gives, a blank line, a short body if useful, then the
|
||||||
|
`Co-Authored-By:` trailer line the owner's prompt gives you, exactly, as the last line.
|
||||||
|
- A message with more than one line goes in `.state/commit-msg.txt` (inside the repository and
|
||||||
|
ignored by git; `/tmp` is refused) and is committed with `git commit -F .state/commit-msg.txt`.
|
||||||
|
|
||||||
|
## The implementer log
|
||||||
|
|
||||||
|
Before you commit, add one row to the table in `docs/implementer-log.md` and include the file in
|
||||||
|
the commit:
|
||||||
|
|
||||||
|
`| <plan>/<task> | <date> | done or stopped | <sessions> | gate pass/fail | deviations | what you did |`
|
||||||
|
|
||||||
|
Be honest: the log is how the owner judges the process. A deviation you log is a finding; one
|
||||||
|
you hide is a defect.
|
||||||
@@ -10,7 +10,7 @@ CGO_ENABLED ?= 0
|
|||||||
GO ?= go
|
GO ?= go
|
||||||
STATICCHECK ?= $(shell command -v staticcheck 2>/dev/null)
|
STATICCHECK ?= $(shell command -v staticcheck 2>/dev/null)
|
||||||
|
|
||||||
.PHONY: all build test lint contract install clean
|
.PHONY: all build test lint contract install clean gate
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -20,6 +20,13 @@ build:
|
|||||||
test:
|
test:
|
||||||
$(GO) test -race ./...
|
$(GO) test -race ./...
|
||||||
|
|
||||||
|
# The implementer's gate (AGENTS.md): formatting, vet, race tests, staticcheck when present. Offline.
|
||||||
|
gate:
|
||||||
|
@test -z "$$(gofmt -l cmd internal tests 2>&1)" || { echo "gofmt: these files need formatting:"; gofmt -l cmd internal tests; exit 1; }
|
||||||
|
$(MAKE) lint
|
||||||
|
$(GO) test -race -count=1 ./...
|
||||||
|
@echo "gate: ok"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
@if [ -n "$(STATICCHECK)" ]; then $(STATICCHECK) ./...; else echo "staticcheck not found; skipped"; fi
|
@if [ -n "$(STATICCHECK)" ]; then $(STATICCHECK) ./...; else echo "staticcheck not found; skipped"; fi
|
||||||
|
|||||||
+17
-9
@@ -61,9 +61,7 @@ Notes:
|
|||||||
|
|
||||||
**Known extract defect:** the Russian UDHR (Acrobat Distiller 5, 2002) comes out with words glued
|
**Known extract defect:** the Russian UDHR (Acrobat Distiller 5, 2002) comes out with words glued
|
||||||
together and digits dropped (recall 0.03; gopdf and pdfium read it cleanly).
|
together and digits dropped (recall 0.03; gopdf and pdfium read it cleanly).
|
||||||
- Keep it as a regression fixture: `tests/testdata/pdf/udhr-ru.pdf`, expected to fail today and
|
- File the upstream issue. No fixture is committed: the document is not ours to redistribute.
|
||||||
marked as such.
|
|
||||||
- File the upstream issue.
|
|
||||||
- Pin `extract v0.3.0` / `reader v0.6.0` in `go.mod` and vendor them, as for every other dependency.
|
- Pin `extract v0.3.0` / `reader v0.6.0` in `go.mod` and vendor them, as for every other dependency.
|
||||||
|
|
||||||
## 4. Detection and fetch
|
## 4. Detection and fetch
|
||||||
@@ -115,8 +113,9 @@ Kyle, 2026-09-25: "one or two workers seems tenable".
|
|||||||
|
|
||||||
- **Pool:** 2 extraction slots and 4 waiting places. A request that waits more than 20 s, or
|
- **Pool:** 2 extraction slots and 4 waiting places. A request that waits more than 20 s, or
|
||||||
finds the queue full, gets `503 {"error":"pdf workers busy"}` with `Retry-After: 10`.
|
finds the queue full, gets `503 {"error":"pdf workers busy"}` with `Retry-After: 10`.
|
||||||
- **Single-flight:** keyed by (caller class, final URL). Concurrent requests for the same
|
- **Single-flight:** keyed by (caller class, content hash). Concurrent requests for the same
|
||||||
document share one download and one extraction.
|
bytes share one extraction. Downloads may still happen twice: a URL is not known to be a PDF
|
||||||
|
until it has been fetched.
|
||||||
- **Per caller:** one PDF job in flight. A second concurrent one gets the existing
|
- **Per caller:** one PDF job in flight. A second concurrent one gets the existing
|
||||||
`429 busy` path.
|
`429 busy` path.
|
||||||
- **Cache first:** a request answered from the cache (§7) never touches the pool.
|
- **Cache first:** a request answered from the cache (§7) never touches the pool.
|
||||||
@@ -136,9 +135,11 @@ Both count toward `dcache` `maxBytes` and its eviction, whole documents at a tim
|
|||||||
|
|
||||||
## 8. What callers get back
|
## 8. What callers get back
|
||||||
|
|
||||||
Text is assembled with page markers, so both agents and digests can cite pages:
|
Text is assembled with a marker before **every** page, so both agents and digests can cite pages:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
--- page 1 ---
|
||||||
|
|
||||||
<page 1 text>
|
<page 1 text>
|
||||||
|
|
||||||
--- page 2 ---
|
--- page 2 ---
|
||||||
@@ -158,7 +159,7 @@ Text is assembled with page markers, so both agents and digests can cite pages:
|
|||||||
- `pages` and `grep` together means grep within those pages.
|
- `pages` and `grep` together means grep within those pages.
|
||||||
- The JSON envelope gains:
|
- The JSON envelope gains:
|
||||||
```
|
```
|
||||||
"pdf": {"pages": N, "title": "…", "outline": [...], "returned": [a, b], "map": bool, "grep_hits": n}
|
"pdf": {"pages", "title", "outline", "extracted", "first", "last", "map", "grep_hits", "note"}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`/v1/fetch`, mode `digest` (`question`):**
|
**`/v1/fetch`, mode `digest` (`question`):**
|
||||||
@@ -231,8 +232,7 @@ All run offline against `tests/testdata/pdf/` fixtures served by an httptest ser
|
|||||||
13. **Digest:** `question` on the 3-page fixture returns a digest containing `(p. ` for a fact
|
13. **Digest:** `question` on the 3-page fixture returns a digest containing `(p. ` for a fact
|
||||||
known to be on one page (the model is stubbed, as in the existing digest tests; the check is
|
known to be on one page (the model is stubbed, as in the existing digest tests; the check is
|
||||||
that the page markers reach the prompt and the prompt line is present).
|
that the page markers reach the prompt and the prompt line is present).
|
||||||
14. **The `udhr-ru` regression fixture** is present and marked as a known failure (skip with a
|
14. (Dropped: no third-party fixture is committed; the extract defect is tracked upstream.)
|
||||||
reason naming the upstream issue).
|
|
||||||
|
|
||||||
## 12. Rollout
|
## 12. Rollout
|
||||||
|
|
||||||
@@ -242,3 +242,11 @@ All run offline against `tests/testdata/pdf/` fixtures served by an httptest ser
|
|||||||
3. The skill update ships with the pin bump to talos, straylight and titan.
|
3. The skill update ships with the pin bump to talos, straylight and titan.
|
||||||
4. Watch `sift_pdf_*` and tirith for a week. The success measure is that the
|
4. Watch `sift_pdf_*` and tirith for a week. The success measure is that the
|
||||||
`cp … pdftotext …` shell pattern disappears from agents' commands.
|
`cp … pdftotext …` shell pattern disappears from agents' commands.
|
||||||
|
|
||||||
|
## 13. Changes after the plan was written (2026-09-25)
|
||||||
|
|
||||||
|
- The implementation plan is `docs/plans/p5a/` (seven tasks, Ornith implementing, acceptance tests
|
||||||
|
first). Where this doc and a task file differ, the task file and its given tests win.
|
||||||
|
- `requestDeadline` 90 s → 210 s and `writeTimeout` 120 s → 240 s, so the PDF path's worst case
|
||||||
|
(120 s download + 20 s queue + 60 s extraction) fits; other paths keep their own timeouts.
|
||||||
|
- The map line reads `pages 1-E extracted` (ASCII hyphen) when the child stopped at its page cap.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Implementer log
|
||||||
|
|
||||||
|
One row per task session that ends in a commit. Newest last.
|
||||||
|
|
||||||
|
| Task | Date | Status | Sessions | Gate | Deviations | Summary |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# P5a task 01: recognise PDFs; give them a larger body cap, a longer deadline, and revalidation
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch -c p5a master` if it does not exist, else `git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `clean, fetch: recognise PDFs; 100 MB / 120 s for PDFs; FetchIf revalidates`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Today every response that is not HTML, JSON or text is `415 unsupported content type`. After this
|
||||||
|
task `clean.Kind` names PDFs, and the fetcher lets a PDF be as large as 100 MB and take 120 s
|
||||||
|
while a web page keeps 5 MB and 20 s. `FetchIf` sends `If-None-Match` / `If-Modified-Since` so a
|
||||||
|
cached document can be revalidated with a 304. Nothing uses these yet (task 06 does).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/clean/kind_test.go`, `internal/fetch/pdf_test.go`
|
||||||
|
- Create: `internal/fetch/pdf.go` (the PDF rule and `FetchIf` live here; keep `fetch.go`'s
|
||||||
|
changes to what the new file needs)
|
||||||
|
- Modify: `internal/clean/clean.go`, `internal/fetch/fetch.go`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package clean
|
||||||
|
|
||||||
|
// Kind classifies a fetched body: "html", "json", "text", "pdf" or "other".
|
||||||
|
func Kind(contentType string, body []byte) string
|
||||||
|
|
||||||
|
package fetch
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultPDFMaxBody = 100 << 20
|
||||||
|
DefaultPDFTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// in Fetcher:
|
||||||
|
PDFMaxBody int64 // 0 = DefaultPDFMaxBody
|
||||||
|
PDFTimeout time.Duration // 0 = DefaultPDFTimeout
|
||||||
|
|
||||||
|
// FetchIf is Fetch with validators: a non-empty etag is sent as If-None-Match and a non-empty
|
||||||
|
// lastModified as If-Modified-Since. A 304 answer returns a Resource with Status 304, an empty
|
||||||
|
// Body and a nil error. With both empty it is exactly Fetch.
|
||||||
|
func (f *Fetcher) FetchIf(ctx context.Context, rawURL, etag, lastModified string) (*Resource, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **`Kind`** (the media type is lower-cased and its parameters dropped, as `MediaKind` does):
|
||||||
|
- `application/pdf` or `application/x-pdf` → `"pdf"`, whatever the body.
|
||||||
|
- `text/html`, `application/xhtml+xml` → `"html"`; `application/json` or `*+json` → `"json"`;
|
||||||
|
`text/plain`, `text/markdown`, `text/csv` → `"text"` (as today).
|
||||||
|
- `application/octet-stream` or `binary/octet-stream` → `"pdf"` if `%PDF-` occurs within the
|
||||||
|
first 1024 bytes of the body, else `"other"`.
|
||||||
|
- empty Content-Type → `"pdf"` if `%PDF-` occurs within the first 1024 bytes, else today's
|
||||||
|
`Sniff` (html / json / text).
|
||||||
|
- anything else → `"other"`.
|
||||||
|
`MediaKind` also returns `"pdf"` for the two PDF types; its other answers do not change.
|
||||||
|
2. **Fetch limits.** A response is a PDF when its Content-Type is a PDF type, or when it is empty
|
||||||
|
or octet-stream and the body starts (within 1024 bytes) with `%PDF-`. For a PDF the body cap
|
||||||
|
is `PDFMaxBody` and the deadline is `PDFTimeout`, both counted from the start of the request;
|
||||||
|
for anything else they stay `MaxBody` and `Timeout`. Over the cap is the existing
|
||||||
|
`413 too large (>N MB decompressed)` with N from the cap that applied.
|
||||||
|
3. **How to get the deadline right.** `Fetch` today makes one context with `Timeout`, and the
|
||||||
|
shared `http.Client` has `Timeout` set too — both would cut a PDF off at 20 s. Make the
|
||||||
|
client's `Timeout` at least the PDF deadline (or unset it and rely on contexts), give the
|
||||||
|
request a context with the **PDF** deadline, and cancel it at the ordinary deadline unless the
|
||||||
|
response has been recognised as a PDF by then (`time.AfterFunc` + a flag you set once the
|
||||||
|
headers, or the first bytes of an octet-stream body, say PDF). Read the body through
|
||||||
|
`io.LimitReader` with the cap that applies (+1 to detect overflow), as today.
|
||||||
|
4. `FetchIf` goes through the same guard, client, limits and error mapping as `Fetch`; only the
|
||||||
|
two request headers differ. Do not copy `Fetch`: factor the shared part.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
- The tests reach an `httptest` server on 127.0.0.1 through a `guard.Guard` with
|
||||||
|
`AllowPrefixes: 127.0.0.0/8`; the guard itself is not yours to change.
|
||||||
|
- `internal/fetch` has no other tests; `internal/api` tests exercise `Fetch` through the API and
|
||||||
|
must stay green.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** Branch as above; copy the two given tests.
|
||||||
|
- [ ] **2. See them fail:** `go test -count=1 ./internal/clean/ ./internal/fetch/` → `Kind`,
|
||||||
|
`FetchIf`, `DefaultPDFMaxBody` undefined.
|
||||||
|
- [ ] **3.** `clean.go`. **4.** `fetch/pdf.go` and the smallest changes to `fetch.go`.
|
||||||
|
- [ ] **5.** `gofmt -w`; `go test -race -count=3 ./internal/clean/ ./internal/fetch/ ./internal/api/` → `ok`.
|
||||||
|
- [ ] **6.** `make gate`. **7.** Row `p5a/01-kind-fetch`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/clean internal/fetch docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The given tests and every earlier test pass under `-race -count=3`; `make gate` prints
|
||||||
|
`gate: ok`; given files byte-identical.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A given test needs a change to `internal/guard`.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# P5a task 02: `pdftext` — extract a PDF in a child process
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `pdftext: extract PDF text in a re-exec'd child (sift pdftext) with a deadline`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Parsing a PDF from the internet is the riskiest thing sift will do. The parser is pure Go
|
||||||
|
(`github.com/go-pdfkit/extract` + `github.com/go-pdfkit/reader`, already in `vendor/`), but a
|
||||||
|
goroutine cannot be killed, so it runs in a **child process**: the sift binary re-executed as
|
||||||
|
`sift pdftext`, reading the PDF on stdin and writing JSON Lines on stdout. The parent kills it at
|
||||||
|
a deadline. This task builds both halves; nothing calls them yet (task 06 does).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/pdftest/pdftest.go` (test support that builds PDFs; never edit it),
|
||||||
|
`tests/testdata/pdf/encrypted-user.pdf`, `tests/testdata/pdf/encrypted-owner.pdf`,
|
||||||
|
`tests/testdata/pdf/README.md`, `internal/pdftext/pdftext_test.go`
|
||||||
|
- Create: `internal/pdftext/pdftext.go` (types, errors, `Extract`, `ChildMain`),
|
||||||
|
`internal/pdftext/outline.go` (the outline walk), `internal/pdftext/runner.go` (`Runner`)
|
||||||
|
- Modify: `cmd/sift/main.go` (one `case "pdftext":` in the command switch), `docs/implementer-log.md`
|
||||||
|
- Delete: `tools.go` (it only pinned the two modules until this package imports them)
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package pdftext
|
||||||
|
|
||||||
|
type OutlineItem struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Page int `json:"page"` // 1-based
|
||||||
|
Level int `json:"level"` // 1 = top level
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta struct {
|
||||||
|
Pages int `json:"pages"` // pages in the document (not only those extracted)
|
||||||
|
Title string `json:"title"` // /Info /Title, "" when absent
|
||||||
|
Outline []OutlineItem `json:"outline"` // never nil: [] when the document has none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc is an extracted document: Text[i] is page i+1's text, for the pages extracted
|
||||||
|
// (len(Text) ≤ Pages). Failed lists pages whose extraction failed (their Text is "").
|
||||||
|
type Doc struct {
|
||||||
|
Meta
|
||||||
|
Text []string
|
||||||
|
Failed []int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEncrypted = errors.New("encrypted PDF (password required)")
|
||||||
|
ErrUnreadable = errors.New("unreadable PDF")
|
||||||
|
ErrNoText = errors.New("no text layer (scanned PDF?)")
|
||||||
|
ErrTimeout = errors.New("pdf extraction timed out")
|
||||||
|
ErrTooMuchText = errors.New("pdf text over the output limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extract writes one {"meta":{…}} line, then one {"page":n,"text":"…"} line per page from 1 up
|
||||||
|
// to min(Pages, maxPages); a page that fails yields {"page":n,"error":"…"} and extraction goes
|
||||||
|
// on. It returns ErrEncrypted when the document needs a user password, ErrUnreadable when it
|
||||||
|
// cannot be opened or has no pages, and never panics.
|
||||||
|
func Extract(pdf []byte, maxPages int, w io.Writer) error
|
||||||
|
|
||||||
|
// ChildMain is `sift pdftext`: flags (-max-pages N, default 2000), the PDF on stdin (read with a
|
||||||
|
// 200 MiB cap), Extract to stdout. Exit codes: 0 ok, 3 ErrEncrypted, 4 ErrUnreadable, 1 anything
|
||||||
|
// else.
|
||||||
|
func ChildMain(args []string, stdin io.Reader, stdout io.Writer) int
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
Exe string // "" = /proc/self/exe
|
||||||
|
Args []string // nil = []string{"pdftext"}; -max-pages is appended
|
||||||
|
Env []string // the child's whole environment; nil = none
|
||||||
|
Deadline time.Duration // 0 = 60 s
|
||||||
|
MaxText int64 // bytes of child output; 0 = 64 MiB
|
||||||
|
MaxPages int // 0 = 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the child, writes pdf to its stdin, and parses its output into a Doc.
|
||||||
|
func (r *Runner) Run(ctx context.Context, pdf []byte) (*Doc, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Extract.**
|
||||||
|
- Open with `reader.Open(pdf)`. `reader.ErrWrongPassword` (from `errors.Is`) → `ErrEncrypted`.
|
||||||
|
Any other open error → `ErrUnreadable`. `PageCount() == 0` → `ErrUnreadable`.
|
||||||
|
- Each page: `extract.Text(doc, n)` with `n` counting from 1, inside a `recover()`; an error
|
||||||
|
or a panic becomes that page's `"error"` line.
|
||||||
|
- Title: the trailer's `/Info` dictionary, key `Title`, as a text string (rule 3).
|
||||||
|
- The meta line comes first and carries the full page count even when `maxPages` is smaller.
|
||||||
|
2. **Outline** (`outline.go`). From the trailer's `/Root` (the catalog) follow `/Outlines` →
|
||||||
|
`/First`, then each item's `/Next`; an item's children start at its `/First` (level + 1).
|
||||||
|
- An item's page comes from `/Dest` (an array whose first element is a page reference), or
|
||||||
|
from `/A` when it is a dictionary with `/S /GoTo` and a `/D` array. Map the reference to a
|
||||||
|
1-based page number by comparing it with `doc.PageRef(i)` for `i` = 1…`PageCount()`.
|
||||||
|
- Skip an item whose destination is a name or cannot be resolved; keep walking.
|
||||||
|
- Stop after 10,000 items, and never visit an object twice (a malformed outline can loop).
|
||||||
|
3. **Text strings** (`/Title` in Info and in outline items): a `reader.String` is raw bytes.
|
||||||
|
Bytes starting `FE FF` are UTF-16BE (decode the rest with `unicode/utf16`); otherwise treat
|
||||||
|
each byte as a Latin-1 code point (close enough to PDFDocEncoding for titles). Trim spaces.
|
||||||
|
4. **ChildMain** runs with a soft memory limit: call `debug.SetMemoryLimit(768 << 20)` first.
|
||||||
|
It must not read any environment variable and must not open the network.
|
||||||
|
5. **Runner.Run.**
|
||||||
|
- `exec.CommandContext` with a context that ends at `Deadline` (or `ctx`, whichever is sooner);
|
||||||
|
`cmd.Env = r.Env` exactly (nil means an empty environment — the parent's never leaks in).
|
||||||
|
- Write the PDF to stdin from a goroutine, then close stdin.
|
||||||
|
- Read stdout through `io.LimitReader(…, MaxText+1)`; more than `MaxText` → kill the child and
|
||||||
|
return `ErrTooMuchText`. Keep at most 4 KiB of stderr, for the error message only.
|
||||||
|
- Always `Wait` for the child, so it is reaped before `Run` returns.
|
||||||
|
- Deadline passed → `ErrTimeout` (even if the kill produced some other error).
|
||||||
|
- Exit 3 → `ErrEncrypted`, exit 4 → `ErrUnreadable`, any other non-zero exit or unparseable
|
||||||
|
output → `ErrUnreadable` wrapped with the first line of stderr.
|
||||||
|
- Parse the lines into a `Doc`. If every extracted page's text is blank after
|
||||||
|
`strings.TrimSpace` (or no page was extracted), return `ErrNoText`.
|
||||||
|
6. **`cmd/sift/main.go`:** `case "pdftext": os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout))`.
|
||||||
|
It is not listed in the help text: users never run it.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
- The tests run the **test binary itself** as the child: `TestMain` becomes the child when
|
||||||
|
`SIFT_PDFTEXT_CHILD=1` is in its environment. The `SIFT_PDFTEXT_SLEEP` and
|
||||||
|
`SIFT_PDFTEXT_PIDFILE` variables exist only in that test file; production code never reads
|
||||||
|
them.
|
||||||
|
- `extract.Text(doc, 1)` returns `""` with a nil error for an image-only page.
|
||||||
|
- Reader API you need: `reader.Open`, `doc.PageCount()`, `doc.PageRef(i) (Ref, bool)`,
|
||||||
|
`doc.Trailer() Dict`, `doc.Resolve(o) (Object, error)`, `Dict.Get(Name) Object`, and the types
|
||||||
|
`reader.Dict`, `reader.Array`, `reader.Ref`, `reader.String`, `reader.Name`. Read their
|
||||||
|
definitions under `vendor/github.com/go-pdfkit/reader/` (`object.go`, `document.go`) — that is
|
||||||
|
inside this repository.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the given files; `git rm tools.go`.
|
||||||
|
- [ ] **2. See them fail:** `go test -count=1 ./internal/pdftext/` (undefined names).
|
||||||
|
- [ ] **3.** `pdftext.go`, **4.** `outline.go`, **5.** `runner.go`, **6.** `cmd/sift/main.go`.
|
||||||
|
- [ ] **7.** `gofmt -w`; `go test -race -count=3 ./internal/pdftext/` → `ok`.
|
||||||
|
- [ ] **8.** `make build && printf 'nope' | bin/sift pdftext; echo "exit $?"` → `exit 4`.
|
||||||
|
- [ ] **9.** `make gate`. **10.** Row `p5a/02-pdftext`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/pdftext internal/pdftest tests/testdata/pdf cmd/sift docs/implementer-log.md # tools.go is already staged by git rm
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The given tests pass under `-race -count=3`, including the deadline test (the child is gone
|
||||||
|
after `Run` returns); `make gate` ok; given files byte-identical; `go.mod` untouched.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- Removing `tools.go` changes `go.mod`, `go.sum` or `vendor/modules.txt` (it must not: the
|
||||||
|
package now imports both modules), or a vendored package is missing.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# P5a task 03: a document cache for PDFs
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `dcache: PDF document cache (per URL and caller class, whole-document eviction)`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extracting a thesis takes a second; downloading it can take a minute. Once a document is
|
||||||
|
extracted, later requests for other pages, a grep, or a question must not fetch or extract it
|
||||||
|
again. This task adds two tables to the existing SQLite cache and four functions. Freshness (the
|
||||||
|
24 h TTL and revalidation) is decided by the caller in task 06, not here.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/dcache/pdf_test.go`
|
||||||
|
- Create: `internal/dcache/pdf.go`
|
||||||
|
- Modify: `internal/dcache/dcache.go` (the two `CREATE TABLE`s, `Size`, `evict`), `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package dcache
|
||||||
|
|
||||||
|
// PDFEntry is one cached PDF: how to revalidate it, its metadata (the pdftext.Meta JSON, opaque
|
||||||
|
// here) and the text of each extracted page (Pages[i] is page i+1).
|
||||||
|
type PDFEntry struct {
|
||||||
|
ContentHash string
|
||||||
|
ETag string
|
||||||
|
LastModified string
|
||||||
|
FetchedAt time.Time
|
||||||
|
Meta []byte
|
||||||
|
Pages []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFKey is the cache key for a document: a hash of the URL and the caller class, like the
|
||||||
|
// other keys here (the URL never appears in the clear).
|
||||||
|
func PDFKey(url, callerClass string) string
|
||||||
|
|
||||||
|
func (d *DB) PutPDF(key string, e PDFEntry) // replaces any entry under key, pages included
|
||||||
|
func (d *DB) GetPDF(key string) (PDFEntry, bool)
|
||||||
|
func (d *DB) TouchPDF(key string, fetchedAt time.Time) // after a 304: only FetchedAt moves; a miss is a no-op
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Tables:
|
||||||
|
- `pdf_doc(k TEXT PRIMARY KEY, content_hash, etag, last_modified, fetched_at INTEGER, meta BLOB, last_hit INTEGER, nbytes INTEGER)`
|
||||||
|
- `pdf_page(k TEXT, page INTEGER, text TEXT, PRIMARY KEY (k, page))`
|
||||||
|
`nbytes` is the document's size (meta plus every page's text) and counts toward `Size()`.
|
||||||
|
2. `PutPDF` writes the document and its pages in **one transaction**, deleting the old pages
|
||||||
|
first, then calls `evict`. `GetPDF` updates `last_hit`, and returns the pages in page order;
|
||||||
|
an empty page keeps its place.
|
||||||
|
3. `evict` treats a PDF document like any other entry, by `last_hit`, and removes it **whole**:
|
||||||
|
its `pdf_doc` row and all its `pdf_page` rows together. A cache never holds half a document.
|
||||||
|
4. Hit and miss counters: add `PDFH`, `PDFM` beside the others (the metrics task reads them later).
|
||||||
|
5. A nil `*DB` is a no-op everywhere, as for the existing methods; a SQL error is logged at
|
||||||
|
`Warn` (never with the key's text or page text) and behaves like a miss.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
- `DigestKey`, `EmbedKey` and `SearchKey` show how keys are hashed (`sha256` of the parts joined
|
||||||
|
with a separator). Follow the same pattern.
|
||||||
|
- The existing `evict` picks the oldest `last_hit` across the three existing tables; extend that
|
||||||
|
union with `pdf_doc`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the given test. **2. See it fail** (undefined names).
|
||||||
|
- [ ] **3.** `dcache.go` schema; `pdf.go`; `Size` and `evict`.
|
||||||
|
- [ ] **4.** `gofmt -w`; `go test -race -count=3 ./internal/dcache/` → `ok` (the existing dcache
|
||||||
|
tests too).
|
||||||
|
- [ ] **5.** `make gate`. **6.** Row `p5a/03-dcache`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/dcache docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The given and existing dcache tests pass under `-race -count=3`; `make gate` ok; given file
|
||||||
|
byte-identical.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# P5a task 04: the extraction pool
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `pdfpool: a few extraction slots, a short queue, single-flight, one job per caller`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A PDF extraction uses a child process and up to a few hundred MB, and sift shares 1 GB with it.
|
||||||
|
Kyle, 2026-09-25: "I don't think we're seeing a ton of PDF requests simultaneously, so having one
|
||||||
|
or two workers seems tenable." This task is the small in-process scheduler that enforces that:
|
||||||
|
two slots, four waiting places, identical requests sharing one job, and no single caller
|
||||||
|
holding more than one job at a time. Generic: it knows nothing about PDFs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/pdfpool/pdfpool_test.go`
|
||||||
|
- Create: `internal/pdfpool/pdfpool.go`
|
||||||
|
- Modify: `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package pdfpool
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBusy = errors.New("pdf workers busy") // queue full, or waited longer than Wait
|
||||||
|
ErrCallerBusy = errors.New("busy") // this caller already has a job in flight
|
||||||
|
)
|
||||||
|
|
||||||
|
// New returns a pool with `slots` jobs running at once, at most `queue` waiting, and a waiter
|
||||||
|
// refused after `wait`.
|
||||||
|
func New(slots, queue int, wait time.Duration) *Pool
|
||||||
|
|
||||||
|
// Do runs fn under the pool and returns its result.
|
||||||
|
func (p *Pool) Do(ctx context.Context, key, caller string, fn func(context.Context) (any, error)) (any, error)
|
||||||
|
|
||||||
|
func (p *Pool) Running() int // jobs holding a slot
|
||||||
|
func (p *Pool) Queued() int // jobs waiting for a slot
|
||||||
|
func (p *Pool) Waiting(key string) int // callers sharing key's job, not counting the one that started it
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules, in the order Do applies them
|
||||||
|
|
||||||
|
1. **Join.** If a job for `key` is already running or queued, wait for its result (count it in
|
||||||
|
`Waiting(key)`) and return it. Joining takes no slot, no queue place, and is allowed even if
|
||||||
|
the caller has another job in flight. `fn` may be nil when joining.
|
||||||
|
2. **Caller.** If `caller` already has a job in flight (running or queued) for another key,
|
||||||
|
return `ErrCallerBusy` at once.
|
||||||
|
3. **Slot.** If fewer than `slots` jobs are running, start at once. Otherwise, if `queue` jobs
|
||||||
|
are already waiting, return `ErrBusy` at once. Otherwise wait, first come first served, until
|
||||||
|
a slot frees (start), `wait` passes (`ErrBusy`), or `ctx` ends (`ctx.Err()`). A waiter that
|
||||||
|
leaves gives up its place.
|
||||||
|
4. **The job's context is not the caller's.** `fn` runs with a context that is **not** cancelled
|
||||||
|
when the caller that started it goes away (`context.WithoutCancel(ctx)`): other callers may
|
||||||
|
be sharing it. The work has its own deadline (the child's). A caller whose `ctx` ends while
|
||||||
|
waiting for a result gets `ctx.Err()`; the job goes on for the others.
|
||||||
|
5. **Results are not cached.** When the job ends, the key is forgotten: the next `Do` for it runs
|
||||||
|
`fn` again. An error from `fn` reaches every sharer. A **panic** in `fn` is recovered,
|
||||||
|
returned as an error to every sharer, and frees the slot and the caller.
|
||||||
|
6. Everything is guarded by one mutex; no goroutine leaks when a waiter gives up.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the given test. **2. See it fail** (undefined names).
|
||||||
|
- [ ] **3.** `pdfpool.go`.
|
||||||
|
- [ ] **4.** `gofmt -w`; `go test -race -count=5 ./internal/pdfpool/` → `ok`.
|
||||||
|
- [ ] **5.** `make gate`. **6.** Row `p5a/04-pdfpool`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/pdfpool docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The given test passes under `-race -count=5`; `make gate` ok; given file byte-identical.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A given test's timing looks impossible to meet without sleeping in production code (that is
|
||||||
|
the owner's bug, per `AGENTS.md`).
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# P5a task 05: render an extracted document for an agent
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `pdfdoc: page markers, the map for long documents, page ranges, grep`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An agent's context is small and a thesis is 60k–150k tokens. This task is the pure function that
|
||||||
|
decides what an agent sees: the whole text with page markers when it fits; otherwise a **map**
|
||||||
|
(title, outline with page numbers, the first pages, and how to ask for more); a **page range**;
|
||||||
|
or **grep** results with page numbers. No I/O, no PDFs: it works on a `*pdftext.Doc`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/pdfdoc/pdfdoc_test.go`
|
||||||
|
- Create: `internal/pdfdoc/pdfdoc.go` (`Render`, `Full`, `Marker`, the map, page ranges),
|
||||||
|
`internal/pdfdoc/grep.go`
|
||||||
|
- Modify: `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package pdfdoc
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Pages string // "" = no range; "a-b", "a" or "a-" (1-based, inclusive)
|
||||||
|
Grep string // "" = no grep; an RE2 pattern
|
||||||
|
OutCap int // characters (runes) the content may use
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rendered struct {
|
||||||
|
Content string
|
||||||
|
Map bool // Content is the map of a document longer than OutCap
|
||||||
|
First, Last int // pages whose text is in Content; 0, 0 for a grep answer
|
||||||
|
GrepHits int // matches found (more may exist than are shown)
|
||||||
|
Note string // "" or how the answer was cut and how to continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBadPages = errors.New("invalid pages")
|
||||||
|
ErrBadGrep = errors.New("invalid grep")
|
||||||
|
)
|
||||||
|
|
||||||
|
func Marker(page int) string // "--- page N ---"
|
||||||
|
func Full(doc *pdftext.Doc) string // every extracted page with its marker
|
||||||
|
func Render(doc *pdftext.Doc, opt Options) (Rendered, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Full.** For each extracted page p: `Marker(p) + "\n\n" + strings.TrimSpace(text)`, joined
|
||||||
|
with `"\n\n"`. The given test shows the exact string.
|
||||||
|
2. **No Pages, no Grep.**
|
||||||
|
- If the rune count of `Full(doc)` is at most `OutCap`: `Content = Full(doc)`, `First = 1`,
|
||||||
|
`Last` = pages extracted.
|
||||||
|
- Otherwise `Map = true` and the content is, in this order:
|
||||||
|
```
|
||||||
|
# <Title, or "Untitled PDF">
|
||||||
|
|
||||||
|
<Pages> pages · ~<T> tokens[ · pages 1-<E> extracted]
|
||||||
|
|
||||||
|
## Outline (only when the outline is not empty)
|
||||||
|
- <title> (p. <page>) (one line per item, indented two spaces per level below 1)
|
||||||
|
|
||||||
|
This document is longer than one answer: ask for pages="a-b" or grep="pattern".
|
||||||
|
|
||||||
|
<leading pages>
|
||||||
|
```
|
||||||
|
- `T` is the rune count of `Full(doc)` divided by 4.
|
||||||
|
- The `pages 1-E extracted` part appears only when fewer pages were extracted than
|
||||||
|
`Pages`. The separator is " · " (U+00B7).
|
||||||
|
- Leading pages are whole pages rendered as in `Full`, from page 1, as many as fit in
|
||||||
|
`OutCap/4` runes. At least page 1 is shown, cut to fit if it alone is larger.
|
||||||
|
- The whole content is at most `OutCap` runes; if the outline is too long, cut the outline,
|
||||||
|
not the header or the instruction line.
|
||||||
|
- `First = 1`, `Last` = the last whole page shown.
|
||||||
|
3. **Pages.**
|
||||||
|
- Parse after trimming spaces: `a-b`, `a`, `a-` (a to the last extracted page). Anything else
|
||||||
|
→ `ErrBadPages`.
|
||||||
|
- `a < 1`, `b < a`, or `a` past the last extracted page → an error wrapping `ErrBadPages`
|
||||||
|
whose text is `pages out of range (1-E)`. `b` past the end is clamped to `E`.
|
||||||
|
- At most **100 pages** and at most `OutCap` runes: whole pages from `a`. A single page larger
|
||||||
|
than `OutCap` is cut to fit.
|
||||||
|
- When the range was cut, `Note` says so and names the rest as `pages="k-b"`: for example
|
||||||
|
`cut at page 13; ask for pages="14-40"`.
|
||||||
|
4. **Grep.**
|
||||||
|
- The pattern must be at most 200 bytes and compile with `regexp` (RE2), else an error
|
||||||
|
wrapping `ErrBadGrep`. Prefix `(?i)` unless the pattern contains an upper-case letter
|
||||||
|
("smart case").
|
||||||
|
- Search every line of every page in the range (all extracted pages when `Pages` is empty).
|
||||||
|
- The first line of the content is `grep "<pattern>": N matches` (`1 match` for one; `no matches`
|
||||||
|
for none). Add `, showing the first 100` when N is over 100.
|
||||||
|
- Then, per match, up to the first 100: the previous line of the same page as
|
||||||
|
`p. P- <line>`, the matching line as `p. P: <line>`, the next line as `p. P- <line>`,
|
||||||
|
then a `--` line. Lines are trimmed of surrounding spaces.
|
||||||
|
- `GrepHits = N`, `First = Last = 0`. The content is at most `OutCap` runes (drop whole
|
||||||
|
match blocks at the end and say so in `Note`). No match is not an error.
|
||||||
|
5. Every length here is in **runes**, never bytes: the cap protects an agent's context, and CJK
|
||||||
|
text is three bytes a rune.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the given test. **2. See it fail** (undefined names).
|
||||||
|
- [ ] **3.** `pdfdoc.go`, **4.** `grep.go`.
|
||||||
|
- [ ] **5.** `gofmt -w`; `go test -race -count=3 ./internal/pdfdoc/` → `ok`.
|
||||||
|
- [ ] **6.** `make gate`. **7.** Row `p5a/05-pdfdoc`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/pdfdoc docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The given test passes; `make gate` ok; given file byte-identical.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# P5a task 06: PDFs through `/v1/fetch` and `/v2/scrape`
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `api: serve PDFs — child extraction via the pool, document cache, map, pages, grep`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Wire tasks 01–05 into the API. A PDF URL fetched through `/v1/fetch` or `/v2/scrape` is:
|
||||||
|
1. extracted once in the child, under the pool;
|
||||||
|
2. cached per page;
|
||||||
|
3. answered with the whole text, the map, a page range, or grep results.
|
||||||
|
|
||||||
|
Later requests for the same document are served from the cache, revalidated after 24 h with a
|
||||||
|
conditional GET. `question` digests the document's text, page markers included, unless the
|
||||||
|
document is too long.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/api/pdf_test.go`
|
||||||
|
- Create: `internal/api/pdf.go` — everything PDF lives here
|
||||||
|
- Modify: `internal/api/api.go` — **only** the hooks listed in rule 7. `api.go` is already 840
|
||||||
|
lines; do not move code into it.
|
||||||
|
- Modify: `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package api
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultPDFTTL = 24 * time.Hour
|
||||||
|
DefaultPDFMaxQuestionChunks = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// in Server:
|
||||||
|
PDFRunner *pdftext.Runner // nil = &pdftext.Runner{} (the child is /proc/self/exe pdftext)
|
||||||
|
PDFPool *pdfpool.Pool // nil = pdfpool.New(2, 4, 20*time.Second)
|
||||||
|
PDFTTL time.Duration // 0 = DefaultPDFTTL
|
||||||
|
PDFMaxQuestionChunks int // 0 = DefaultPDFMaxQuestionChunks
|
||||||
|
|
||||||
|
// in v1Req:
|
||||||
|
Pages string `json:"pages"`
|
||||||
|
Grep string `json:"grep"`
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults are applied where the field is read: a zero `Server` from `New` must work. Build the
|
||||||
|
default pool once, with a `sync.Once` — never one per request.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Cache first.**
|
||||||
|
- Before fetching, look up `dcache.PDFKey(rawURL, s.callerClass(c))` in `s.cache()` (which
|
||||||
|
may be nil: then there is no cache).
|
||||||
|
- **Fresh** (now − `FetchedAt` < TTL): build the `*pdftext.Doc` from the entry (unmarshal
|
||||||
|
`Meta`; `Text` = `Pages`). Answer with no network call and no child. The source is marked
|
||||||
|
`"cached": true`.
|
||||||
|
- **Stale:** `s.Fetch.FetchIf(ctx, rawURL, e.ETag, e.LastModified)`.
|
||||||
|
- 304 → `TouchPDF(key, now)` and answer from the entry, with no child.
|
||||||
|
- 200 → go on as for a new document.
|
||||||
|
- **Not cached:** the usual `s.Fetch.Fetch`, under the existing `acquire`/`release`.
|
||||||
|
2. **A PDF** is `clean.Kind(res.ContentType, res.Body) == "pdf"`. Every other kind goes through
|
||||||
|
today's path unchanged. Use `clean.Kind` in place of the `MediaKind`/`Sniff` pair in both
|
||||||
|
`doFetch` and `cleanFetched`. `"other"` stays `415`.
|
||||||
|
3. **Extraction.**
|
||||||
|
- Call `s.PDFPool.Do(ctx, class+"|"+res.SHA256(), c.Name, fn)`, where `fn` runs
|
||||||
|
`s.PDFRunner.Run(jobCtx, res.Body)`.
|
||||||
|
- The key is the **content hash**, so identical bytes are extracted once however many callers
|
||||||
|
or URLs ask. The key includes the caller class, so a restricted caller never shares a job
|
||||||
|
with the fleet.
|
||||||
|
- Then `PutPDF` under the URL key with: the content hash; the response's `ETag` and
|
||||||
|
`Last-Modified`; `FetchedAt` = now; `Meta` = the JSON of `doc.Meta`; `Pages` = `doc.Text`.
|
||||||
|
4. **Errors**, all through the existing `writeErr` shape (`{"success":false,"error":…,"hint":…}`):
|
||||||
|
|
||||||
|
| cause | status | error | hint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pdftext.ErrNoText` | 422 | `no text layer (scanned PDF?)` | `ocr` |
|
||||||
|
| `pdftext.ErrEncrypted` | 422 | `encrypted PDF (password required)` | |
|
||||||
|
| `pdftext.ErrUnreadable` | 422 | `unreadable PDF` | |
|
||||||
|
| `pdftext.ErrTimeout` | 422 | `pdf extraction timed out` | |
|
||||||
|
| `pdftext.ErrTooMuchText` | 422 | `pdf text over the output limit` | |
|
||||||
|
| `pdfpool.ErrBusy` | 503 | `pdf workers busy`, **header `Retry-After: 10`** | |
|
||||||
|
| `pdfpool.ErrCallerBusy` | 429 | `busy` | |
|
||||||
|
| `pdfdoc.ErrBadPages` / `ErrBadGrep` | 400 | the error's text | |
|
||||||
|
| `pages` or `grep` on a non-PDF | 400 | `pages and grep apply to PDFs only` | |
|
||||||
|
| `question` on a document over `PDFMaxQuestionChunks` chunks | 422 | `document too long for question (N chunks); use grep or pages` | |
|
||||||
|
|
||||||
|
A PDF path never answers 500 for anything the document did.
|
||||||
|
5. **`/v1/fetch`.**
|
||||||
|
- **Mode `clean`:** `content` is `pdfdoc.Render(doc, {Pages, Grep, OutCap: s.OutCap}).Content`.
|
||||||
|
- The envelope gains:
|
||||||
|
```
|
||||||
|
"pdf": {"pages", "title", "outline", "extracted", "first", "last", "map", "grep_hits", "note"}
|
||||||
|
```
|
||||||
|
`extracted` is `len(doc.Text)`; `outline` is `doc.Outline`; the rest come from `Rendered`.
|
||||||
|
- **Mode `digest`:** the text is `pdfdoc.Full(doc)`. First count
|
||||||
|
`len(digest.Split(full, digest.ChunkTokens))`; if it is over `PDFMaxQuestionChunks`, answer
|
||||||
|
the 422 above **before** any model call. Otherwise run the digest as for any page, with
|
||||||
|
`full` as the content.
|
||||||
|
- **Mode `raw`** on a PDF: 400 `raw mode does not apply to PDFs`.
|
||||||
|
- `sizes.raw_tokens` is the rune count of `Full(doc)` / 4 for a PDF (there is no body on a
|
||||||
|
cache hit).
|
||||||
|
6. **`/v2/scrape`.**
|
||||||
|
- `markdown` is `Render(doc, {OutCap: s.OutCap}).Content`; `html` is `""`.
|
||||||
|
- `metadata.title` is `doc.Title`; `metadata.numPages` is `doc.Pages`; `metadata.contentType`
|
||||||
|
is the response's (`application/pdf` on a cache hit).
|
||||||
|
7. **`api.go` hooks (the only edits allowed there):**
|
||||||
|
- the four `Server` fields and two `v1Req` fields;
|
||||||
|
- the `Kind` switch in `doFetch`/`cleanFetched`, now returning the doc;
|
||||||
|
- one call into `pdf.go` in `v1fetch` and in `scrape`;
|
||||||
|
- `Retry-After` in `writeFetchErr` for a 503.
|
||||||
|
|
||||||
|
`doFetch` and `cleanFetched` may gain a result, `doc *pdftext.Doc` (nil unless a PDF).
|
||||||
|
8. **Logging:**
|
||||||
|
- Keep today's `fetch`/`scrape` log lines and add `pdf_pages` (a count) to them.
|
||||||
|
- Never log the title, the outline or any text.
|
||||||
|
- A restricted caller's lines go through the existing helpers (host blanked); the given test
|
||||||
|
reads every log record.
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
- The tests set `s.Digest = &digest.Engine{Cache: db}` only to give `s.cache()` a database: no
|
||||||
|
model is configured.
|
||||||
|
- The child in the tests is the test binary (`TestMain` in the given file).
|
||||||
|
- `setupAPI`, `postJSON` and `recHandler` come from `api_test.go` in the same package.
|
||||||
|
- `requestDeadline` (210 s) and `writeTimeout` (240 s) were raised by the owner for this path:
|
||||||
|
download ≤ 120 s + queue ≤ 20 s + extraction ≤ 60 s.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the given test. **2. See it fail** (undefined fields).
|
||||||
|
- [ ] **3.** `pdf.go` (cache, extraction, errors, render), then the `api.go` hooks.
|
||||||
|
- [ ] **4.** `gofmt -w`; `go test -race -count=3 ./internal/api/` → `ok`: the new tests and
|
||||||
|
every existing api test (search, scrape, restricted logging, write timeout).
|
||||||
|
- [ ] **5.** `make gate`. **6.** Row `p5a/06-api`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/api docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `go test -race -count=3 ./internal/api/` is ok; `make gate` ok; given file byte-identical;
|
||||||
|
`api.go` grew by the hooks only (the owner will diff it).
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A rule here contradicts an existing api test.
|
||||||
|
- The pool's default wait (20 s) makes a test take longer than the test allows. The given tests
|
||||||
|
set their own pool where timing matters.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# P5a task 07: page citations, metrics, CLI flags, skill, unit, README
|
||||||
|
|
||||||
|
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `P5a: digests cite pages; sift_pdf_* metrics; sift fetch --pages/--grep; skill, unit and README`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Finish P5a:
|
||||||
|
- digests of PDFs cite `(p. N)`;
|
||||||
|
- the PDF path is visible in `/metrics`;
|
||||||
|
- `sift fetch` can ask for pages and grep;
|
||||||
|
- the agents' skill tells them PDFs work;
|
||||||
|
- the unit gets the memory the child needs;
|
||||||
|
- the README describes all of it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/digest/pages_test.go`, `internal/metrics/pdf_test.go`, `cmd/sift/fetchpdf_test.go`
|
||||||
|
- Modify:
|
||||||
|
- `internal/digest/digest.go` (prompt line, `PromptVersion`)
|
||||||
|
- `internal/metrics/metrics.go`
|
||||||
|
- `internal/api/pdf.go` (the metric calls)
|
||||||
|
- `cmd/sift/fetchcmd.go`
|
||||||
|
- `skill/SKILL.md`
|
||||||
|
- `nix/module.nix` (**only** `MemoryMax`)
|
||||||
|
- `README.md`
|
||||||
|
- `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Digest.**
|
||||||
|
- Add one sentence to `systemPrompt`: `When the document has "--- page N ---" markers, cite
|
||||||
|
the page of each fact as (p. N).`
|
||||||
|
- Set `PromptVersion = "3"`. The digest cache keys on it, so digests made with the old
|
||||||
|
prompt are not served.
|
||||||
|
- `Split` already keeps markers whole; the given test pins that.
|
||||||
|
2. **Metrics** (`metrics.go`):
|
||||||
|
- `func (s *Set) ObservePDF(outcome string, d time.Duration, pages int)`. It feeds:
|
||||||
|
- the counter `sift_pdf_extract_total{outcome}`;
|
||||||
|
- the histogram `sift_pdf_extract_seconds`;
|
||||||
|
- the counter `sift_pdf_pages_total`.
|
||||||
|
|
||||||
|
`outcome` must be one of `ok`, `no_text`, `encrypted`, `unreadable`, `timeout`,
|
||||||
|
`too_large`, `busy`; anything else is counted as `other`. The label never carries raw
|
||||||
|
text: the given test sends `blocked: evil.example`.
|
||||||
|
- `func (s *Set) ObservePDFWait(d time.Duration)` feeds the histogram
|
||||||
|
`sift_pdf_pool_wait_seconds`.
|
||||||
|
- `func (s *Set) PDFCacheHit()` feeds the counter `sift_pdf_cache_hits_total`.
|
||||||
|
- Register them on the Set's own registry, like `sift_requests_total`.
|
||||||
|
- In `internal/api/pdf.go`:
|
||||||
|
- call `ObservePDF` once per extraction attempt, with the outcome and the pages extracted;
|
||||||
|
- call `ObservePDFWait` with the time spent waiting in `PDFPool.Do` before the job
|
||||||
|
started (measure around `Do`: total minus the job's own time);
|
||||||
|
- call `PDFCacheHit` for every answer served from the cache, fresh or after a 304.
|
||||||
|
3. **CLI** (`fetchcmd.go`).
|
||||||
|
- Parsing becomes a struct, and today's tuple function stays as a wrapper so the existing
|
||||||
|
tests keep compiling:
|
||||||
|
```go
|
||||||
|
type fetchOpts struct {
|
||||||
|
URL, Mode, JQ, Question, Pages, Grep string
|
||||||
|
NoContext, Recover, JSON bool
|
||||||
|
}
|
||||||
|
func parseFetchOpts(args []string) (fetchOpts, error) // the parser; knows --pages and --grep (both take a value)
|
||||||
|
func parseFetchArgs(args []string) (…the same 8 results as today…) // calls parseFetchOpts
|
||||||
|
func fetchBody(o fetchOpts) map[string]any // the /v1/fetch request body
|
||||||
|
```
|
||||||
|
- `fetchBody` builds today's body: url, mode (defaulting to digest when there is a question,
|
||||||
|
else clean), jq, question, context (question set and not `--no-context`), recover. It adds
|
||||||
|
`"pages"` and `"grep"` **only when set**.
|
||||||
|
- The command uses `parseFetchOpts` and `fetchBody`.
|
||||||
|
- Add `[--pages a-b] [--grep RE]` to `fetchUsage`.
|
||||||
|
- The given `cmd/sift/fetchpdf_test.go` and the existing flag tests must pass.
|
||||||
|
4. **Skill** (`skill/SKILL.md`):
|
||||||
|
- Remove the `415 unsupported type` row's mention of PDF.
|
||||||
|
- Add a short "PDFs" section:
|
||||||
|
- PDFs work;
|
||||||
|
- a long one answers with a map (outline with page numbers, first pages);
|
||||||
|
- then use `--pages 41-60` or `--grep 'pattern'`, or `--question` for a digest that cites
|
||||||
|
pages;
|
||||||
|
- scanned PDFs return `no text layer`;
|
||||||
|
- never download a PDF and run `pdftotext` yourself.
|
||||||
|
5. **Unit** (`nix/module.nix`): `MemoryMax = "1G";` (was `"512M"`). Nothing else in the file
|
||||||
|
changes.
|
||||||
|
6. **README:**
|
||||||
|
- a "PDF documents" section covering: detection, limits (100 MB, 120 s download, 60 s
|
||||||
|
extraction, 2,000 pages), the child process, the pool (2 slots, 4 waiting, 20 s), the
|
||||||
|
cache (24 h, conditional GET), the answers (full / map / pages / grep / digest with
|
||||||
|
citations), the errors table from task 06, and the metrics;
|
||||||
|
- note that the engine is `go-pdfkit/extract` v0.3.0, chosen by the bake-off in `docs/P5a.md` §3.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch p5a`; copy the three given tests. **2. See them fail.**
|
||||||
|
- [ ] **3.** digest, **4.** metrics and the calls in `pdf.go`, **5.** CLI, **6.** skill, unit,
|
||||||
|
README.
|
||||||
|
- [ ] **7.** `gofmt -w`; `make gate`.
|
||||||
|
- [ ] **8.** `make build && bin/sift fetch 2>&1 | grep -c -- '--pages'` prints `1`.
|
||||||
|
- [ ] **9.** Row `p5a/07-digest-cli-docs`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/digest internal/metrics internal/api/pdf.go cmd/sift skill nix/module.nix README.md docs/implementer-log.md
|
||||||
|
git commit -F .state/commit-msg.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- All tests pass; `make gate` ok; given files byte-identical; the `nix/module.nix` diff is the
|
||||||
|
one line.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# P5a implementation plan: PDF documents
|
||||||
|
|
||||||
|
> **For the implementing model:** do not work from this file. The owner gives you one task file
|
||||||
|
> at a time. This file is the index for the owner and the reviewer.
|
||||||
|
|
||||||
|
**Goal:** `docs/P5a.md`. sift fetches, extracts and serves PDFs, so agents stop running
|
||||||
|
`pdftotext` in the shell. Engine: `github.com/go-pdfkit/extract` v0.3.0 (bake-off, P5a §3),
|
||||||
|
vendored by the owner before the run.
|
||||||
|
|
||||||
|
| Task | Package(s) | Given |
|
||||||
|
|---|---|---|
|
||||||
|
| 01-kind-fetch | `internal/clean`, `internal/fetch` | `clean/kind_test.go`, `fetch/pdf_test.go` |
|
||||||
|
| 02-pdftext | `internal/pdftext`, `cmd/sift` (one dispatch line) | `pdftext/pdftext_test.go`, `internal/pdftest/pdftest.go`, `tests/testdata/pdf/*` |
|
||||||
|
| 03-dcache | `internal/dcache` | `dcache/pdf_test.go` |
|
||||||
|
| 04-pdfpool | `internal/pdfpool` | `pdfpool/pdfpool_test.go` |
|
||||||
|
| 05-pdfdoc | `internal/pdfdoc` | `pdfdoc/pdfdoc_test.go` |
|
||||||
|
| 06-api | `internal/api` | `api/pdf_test.go` |
|
||||||
|
| 07-digest-cli-docs | `internal/digest`, `internal/metrics`, `cmd/sift`, `internal/api` (metric calls), `skill/`, `nix/module.nix`, `README.md` | `digest/pages_test.go`, `metrics/pdf_test.go` |
|
||||||
|
|
||||||
|
**Order matters:** 05 imports 02's types; 06 uses 01–05; 07 wires metrics into 06's file.
|
||||||
|
|
||||||
|
**How this plan was made:** acceptance tests first, no reference implementation. The given tests
|
||||||
|
compiled against a panic-only skeleton of every new name, on this repository at the plan
|
||||||
|
commit, and failed there for the intended reasons; every earlier test stayed green. The PDFs the
|
||||||
|
tests use are built by `internal/pdftest` (a given file) at test time. The only binary fixtures
|
||||||
|
are two small qpdf-encrypted copies of the three-page test document (`tests/testdata/pdf/README.md`).
|
||||||
|
|
||||||
|
**Owner changes made before the run** (plan commit):
|
||||||
|
- `go.mod`, `go.sum`, `vendor/`: `go-pdfkit/extract` v0.3.0, `go-pdfkit/reader` v0.6.0 (and its
|
||||||
|
`pdffont`), pinned through `tools.go` until task 02 imports them. `flake.nix` uses
|
||||||
|
`vendorHash = null`, so the nix build reads `vendor/`.
|
||||||
|
- `internal/api/api_test.go`: `TestUnsupportedType` now uses `application/zip`; a PDF is no
|
||||||
|
longer unsupported.
|
||||||
|
- `gofmt -w` on `internal/clean/clean.go`, `internal/guard/guard_test.go`,
|
||||||
|
`internal/guard/iana.go` (whitespace only), so the new `make gate` target passes on master.
|
||||||
|
- `AGENTS.md`, `tools/run-plan.sh`, `docs/implementer-log.md`, `make gate`.
|
||||||
|
|
||||||
|
**Attribution:** `tools/run-plan.sh` gives the model the exact trailer
|
||||||
|
`Co-Authored-By: <model> <<model>@llama.cpp.invalid>` and refuses a commit without it.
|
||||||
|
|
||||||
|
**Where it runs:** a separate checkout (`~/src/sift-impl` on straylight), branch `p5a`. Nothing
|
||||||
|
reaches orion until the owner's review, the merge, and straylight's pin bump.
|
||||||
|
|
||||||
|
## Changes during the run
|
||||||
|
|
||||||
|
(none yet)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// P5a task 07: `sift fetch --pages` and `--grep`.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFetchOptsPDF(t *testing.T) {
|
||||||
|
o, err := parseFetchOpts([]string{"https://example.org/t.pdf", "--pages", "41-60", "--grep", "attention head"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.URL != "https://example.org/t.pdf" || o.Pages != "41-60" || o.Grep != "attention head" {
|
||||||
|
t.Errorf("got %+v", o)
|
||||||
|
}
|
||||||
|
o, err = parseFetchOpts([]string{"--grep", "x", "--question", "why", "https://example.org/t.pdf"})
|
||||||
|
if err != nil || o.URL != "https://example.org/t.pdf" || o.Grep != "x" || o.Question != "why" {
|
||||||
|
t.Errorf("flags before the URL: %+v %v", o, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fetchUsage, "--pages") || !strings.Contains(fetchUsage, "--grep") {
|
||||||
|
t.Errorf("usage lacks the new flags: %s", fetchUsage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchBodyPDFFields(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(fetchBody(fetchOpts{URL: "https://example.org/a.pdf"}))
|
||||||
|
if strings.Contains(string(b), `"pages"`) || strings.Contains(string(b), `"grep"`) {
|
||||||
|
t.Errorf("unset pages/grep must not be sent: %s", b)
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(fetchBody(fetchOpts{URL: "https://example.org/a.pdf", Pages: "2-3", Grep: "fox"}))
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
if m["pages"] != "2-3" || m["grep"] != "fox" || m["url"] != "https://example.org/a.pdf" || m["mode"] != "clean" {
|
||||||
|
t.Errorf("body = %s", b)
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(fetchBody(fetchOpts{URL: "u", Question: "q"}))
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
if m["mode"] != "digest" || m["question"] != "q" || m["context"] != true {
|
||||||
|
t.Errorf("question body = %s (mode must default to digest, context on)", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// P5a task 06: PDFs through /v1/fetch and /v2/scrape — extraction in the child, the pool, the
|
||||||
|
// document cache, the map, pages, grep, and the errors.
|
||||||
|
//
|
||||||
|
// The child is the test binary itself (TestMain below), as in internal/pdftext's tests.
|
||||||
|
// SIFT_PDFTEXT_RUNLOG makes every child start append a line to a file, so a test can count
|
||||||
|
// extractions; SIFT_PDFTEXT_SLEEP slows a child down. Both hooks live only in this file.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/auth"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/digest"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/guard"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdftest"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv("SIFT_PDFTEXT_CHILD") == "1" {
|
||||||
|
if p := os.Getenv("SIFT_PDFTEXT_RUNLOG"); p != "" {
|
||||||
|
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = f.WriteString("run\n")
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, err := time.ParseDuration(os.Getenv("SIFT_PDFTEXT_SLEEP")); err == nil {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout))
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func threePDF() []byte {
|
||||||
|
return pdftest.Build(pdftest.Doc{
|
||||||
|
Title: "Three Pages",
|
||||||
|
Pages: []string{"Chapter one begins here.", "The quick brown fox is on page two.", "Last page."},
|
||||||
|
Outline: []pdftest.Outline{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func longPDF(n int) []byte {
|
||||||
|
var pages []string
|
||||||
|
for p := 1; p <= n; p++ {
|
||||||
|
pages = append(pages, pdftest.Lorem(p, 40))
|
||||||
|
}
|
||||||
|
return pdftest.Build(pdftest.Doc{Title: "Long Thesis", Pages: pages, Outline: []pdftest.Outline{{Title: "1 Introduction", Page: 1, Level: 1}, {Title: "2 Method", Page: n / 2, Level: 1}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// origin serves one PDF and counts GETs. It honours If-None-Match against its current ETag.
|
||||||
|
type origin struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ct string
|
||||||
|
body []byte
|
||||||
|
etag string
|
||||||
|
gets atomic.Int32
|
||||||
|
srv *httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrigin(t *testing.T, ct string, body []byte) *origin {
|
||||||
|
o := &origin{ct: ct, body: body, etag: `"v1"`}
|
||||||
|
o.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
o.gets.Add(1)
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
if r.Header.Get("If-None-Match") == o.etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", o.etag)
|
||||||
|
if o.ct != "" {
|
||||||
|
w.Header().Set("Content-Type", o.ct)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(o.body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(o.srv.Close)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *origin) replace(body []byte, etag string) {
|
||||||
|
o.mu.Lock()
|
||||||
|
o.body, o.etag = body, etag
|
||||||
|
o.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type pdfRig struct {
|
||||||
|
s *Server
|
||||||
|
fleet string // bearer key of caller "test" (hermes-fleet)
|
||||||
|
other string // bearer key of caller "other" (hermes-fleet)
|
||||||
|
gs string // bearer key of restricted caller "straylight-gs"
|
||||||
|
runlog string
|
||||||
|
logs *recHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPDFRig(t *testing.T, childEnv ...string) *pdfRig {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
keys := filepath.Join(dir, "keys.toml")
|
||||||
|
key := func(name, ws string) string {
|
||||||
|
out := filepath.Join(dir, name)
|
||||||
|
if err := auth.Add(keys, name, ws, out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
r := &pdfRig{fleet: key("test", "hermes-fleet"), other: key("other", "hermes-fleet"), gs: key("straylight-gs", "guardian-star"), runlog: filepath.Join(dir, "runs"), logs: &recHandler{}}
|
||||||
|
store, err := auth.Load(keys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
||||||
|
s := New("test", g, &fetch.Fetcher{Guard: g, UA: "sift-test"}, store, slog.New(r.logs))
|
||||||
|
s.PermitRestricted = []string{"straylight-gs"}
|
||||||
|
db, err := dcache.Open(filepath.Join(dir, "cache.db"), 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
s.Digest = &digest.Engine{Cache: db} // the cache lives on the digest engine; no model is configured
|
||||||
|
s.PDFRunner = &pdftext.Runner{Exe: os.Args[0], Env: append([]string{"SIFT_PDFTEXT_CHILD=1", "SIFT_PDFTEXT_RUNLOG=" + r.runlog}, childEnv...), Deadline: 20 * time.Second}
|
||||||
|
r.s = s
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pdfRig) runs() int {
|
||||||
|
b, _ := os.ReadFile(r.runlog)
|
||||||
|
return strings.Count(string(b), "run\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pdfRig) fetch(t *testing.T, key string, body map[string]any) (int, map[string]any, http.Header) {
|
||||||
|
t.Helper()
|
||||||
|
rec := postJSON(t, r.s, "/v1/fetch", key, body)
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
return rec.Code, out, rec.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pdfInfo(t *testing.T, out map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
p, ok := out["pdf"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("envelope has no pdf object: %v", out)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFCleanFetch(t *testing.T) {
|
||||||
|
for _, ct := range []string{"application/pdf", "application/octet-stream", ""} {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
o := newOrigin(t, ct, threePDF())
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("%q: %d %v", ct, code, out)
|
||||||
|
}
|
||||||
|
content, _ := out["content"].(string)
|
||||||
|
if !strings.Contains(content, "--- page 2 ---") || !strings.Contains(content, "quick brown fox") || out["mode"] != "clean" {
|
||||||
|
t.Errorf("%q: content %q mode %v", ct, content, out["mode"])
|
||||||
|
}
|
||||||
|
p := pdfInfo(t, out)
|
||||||
|
if p["pages"] != 3.0 || p["title"] != "Three Pages" || p["map"] != false || p["first"] != 1.0 || p["last"] != 3.0 {
|
||||||
|
t.Errorf("%q: pdf = %v", ct, p)
|
||||||
|
}
|
||||||
|
if ol, _ := p["outline"].([]any); len(ol) != 2 {
|
||||||
|
t.Errorf("%q: outline = %v", ct, p["outline"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFMap(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
r.s.OutCap = 20000
|
||||||
|
o := newOrigin(t, "application/pdf", longPDF(40)) // ~40 × 2,700 characters
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("%d %v", code, out)
|
||||||
|
}
|
||||||
|
content, _ := out["content"].(string)
|
||||||
|
p := pdfInfo(t, out)
|
||||||
|
if p["map"] != true || !strings.Contains(content, "## Outline") || !strings.Contains(content, "- 2 Method (p. 20)") || len([]rune(content)) > 20000 {
|
||||||
|
t.Errorf("map: pdf %v, %d chars:\n%.400s", p, len([]rune(content)), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFPagesAndGrep(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
o := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "pages": "2-3"})
|
||||||
|
content, _ := out["content"].(string)
|
||||||
|
if code != 200 || strings.Contains(content, "--- page 1 ---") || !strings.Contains(content, "--- page 3 ---") {
|
||||||
|
t.Errorf("pages 2-3: %d %q", code, content)
|
||||||
|
}
|
||||||
|
if p := pdfInfo(t, out); p["first"] != 2.0 || p["last"] != 3.0 {
|
||||||
|
t.Errorf("pages 2-3: pdf %v", p)
|
||||||
|
}
|
||||||
|
for _, bad := range []map[string]any{{"pages": "0-1"}, {"pages": "9"}, {"grep": strings.Repeat("a", 201)}, {"grep": "(unclosed"}} {
|
||||||
|
bad["url"] = o.srv.URL
|
||||||
|
if code, out, _ := r.fetch(t, r.fleet, bad); code != 400 || out["success"] != false {
|
||||||
|
t.Errorf("%v: %d %v, want 400", bad, code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code, out, _ = r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "grep": "quick brown"})
|
||||||
|
content, _ = out["content"].(string)
|
||||||
|
if code != 200 || !strings.Contains(content, "p. 2: The quick brown fox is on page two.") || pdfInfo(t, out)["grep_hits"] != 1.0 {
|
||||||
|
t.Errorf("grep: %d %q %v", code, content, out["pdf"])
|
||||||
|
}
|
||||||
|
// pages and grep on a non-PDF are refused rather than ignored.
|
||||||
|
html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = io.WriteString(w, "<html><body><p>hi</p></body></html>")
|
||||||
|
}))
|
||||||
|
defer html.Close()
|
||||||
|
if code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": html.URL, "pages": "1"}); code != 400 {
|
||||||
|
t.Errorf("pages on html: %d %v, want 400", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFCache(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
o := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
get := func(extra map[string]any) string {
|
||||||
|
body := map[string]any{"url": o.srv.URL}
|
||||||
|
for k, v := range extra {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, body)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("%v: %d %v", extra, code, out)
|
||||||
|
}
|
||||||
|
c, _ := out["content"].(string)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
get(nil)
|
||||||
|
if o.gets.Load() != 1 || r.runs() != 1 {
|
||||||
|
t.Fatalf("first fetch: %d GETs, %d extractions; want 1 and 1", o.gets.Load(), r.runs())
|
||||||
|
}
|
||||||
|
if c := get(map[string]any{"pages": "2"}); !strings.Contains(c, "quick brown fox") {
|
||||||
|
t.Errorf("cached pages=2: %q", c)
|
||||||
|
}
|
||||||
|
if o.gets.Load() != 1 || r.runs() != 1 {
|
||||||
|
t.Errorf("fresh cache hit: %d GETs, %d extractions; want still 1 and 1", o.gets.Load(), r.runs())
|
||||||
|
}
|
||||||
|
// Stale: a conditional GET, answered 304, re-uses the text.
|
||||||
|
r.s.PDFTTL = time.Nanosecond
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
get(map[string]any{"grep": "Chapter"})
|
||||||
|
if o.gets.Load() != 2 || r.runs() != 1 {
|
||||||
|
t.Errorf("after 304: %d GETs, %d extractions; want 2 and 1", o.gets.Load(), r.runs())
|
||||||
|
}
|
||||||
|
// The document changed: a full GET and a new extraction.
|
||||||
|
o.replace(pdftest.Build(pdftest.Doc{Pages: []string{"Brand new text."}}), `"v2"`)
|
||||||
|
if c := get(nil); !strings.Contains(c, "Brand new text.") {
|
||||||
|
t.Errorf("changed document: %q", c)
|
||||||
|
}
|
||||||
|
if o.gets.Load() != 3 || r.runs() != 2 {
|
||||||
|
t.Errorf("after change: %d GETs, %d extractions; want 3 and 2", o.gets.Load(), r.runs())
|
||||||
|
}
|
||||||
|
if DefaultPDFTTL != 24*time.Hour {
|
||||||
|
t.Errorf("DefaultPDFTTL = %v, want 24h", DefaultPDFTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFErrors(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
enc, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
want string
|
||||||
|
hint string
|
||||||
|
}{
|
||||||
|
{"scanned", pdftest.Build(pdftest.Doc{Pages: []string{"", ""}, Scanned: []int{1, 2}}), "no text layer", "ocr"},
|
||||||
|
{"encrypted", enc, "encrypted", ""},
|
||||||
|
{"garbage", []byte("%PDF-1.4\nthis is not really a pdf"), "unreadable", ""},
|
||||||
|
} {
|
||||||
|
o := newOrigin(t, "application/pdf", tc.body)
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||||
|
msg, _ := out["error"].(string)
|
||||||
|
if code != 422 || !strings.Contains(msg, tc.want) || (tc.hint != "" && out["hint"] != tc.hint) {
|
||||||
|
t.Errorf("%s: %d %v; want 422 %q hint %q", tc.name, code, out, tc.want, tc.hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFTimeout(t *testing.T) {
|
||||||
|
r := newPDFRig(t, "SIFT_PDFTEXT_SLEEP=10s")
|
||||||
|
r.s.PDFRunner.Deadline = 300 * time.Millisecond
|
||||||
|
o := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||||
|
if msg, _ := out["error"].(string); code != 422 || !strings.Contains(msg, "timed out") {
|
||||||
|
t.Errorf("slow child: %d %v; want 422 … timed out", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFBusy(t *testing.T) {
|
||||||
|
r := newPDFRig(t, "SIFT_PDFTEXT_SLEEP=1500ms")
|
||||||
|
r.s.PDFPool = pdfpool.New(1, 0, 100*time.Millisecond)
|
||||||
|
a := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
b := newOrigin(t, "application/pdf", longPDF(2))
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() {
|
||||||
|
code, _, _ := r.fetch(t, r.fleet, map[string]any{"url": a.srv.URL})
|
||||||
|
done <- code
|
||||||
|
}()
|
||||||
|
time.Sleep(400 * time.Millisecond) // the first extraction holds the only slot
|
||||||
|
code, out, hdr := r.fetch(t, r.other, map[string]any{"url": b.srv.URL})
|
||||||
|
if code != 503 || hdr.Get("Retry-After") != "10" || !strings.Contains(fmt.Sprint(out["error"]), "busy") {
|
||||||
|
t.Errorf("second caller with the pool full: %d %v Retry-After %q; want 503, busy, 10", code, out, hdr.Get("Retry-After"))
|
||||||
|
}
|
||||||
|
code, out, _ = r.fetch(t, r.fleet, map[string]any{"url": b.srv.URL})
|
||||||
|
if code != 429 {
|
||||||
|
t.Errorf("same caller, second document while its first is running: %d %v; want 429", code, out)
|
||||||
|
}
|
||||||
|
if c := <-done; c != 200 {
|
||||||
|
t.Errorf("first fetch: %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFRestrictedCaller(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
o := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
if code, out, _ := r.fetch(t, r.gs, map[string]any{"url": o.srv.URL}); code != 200 {
|
||||||
|
t.Fatalf("restricted fetch: %d %v", code, out)
|
||||||
|
}
|
||||||
|
r.logs.mu.Lock()
|
||||||
|
for _, rec := range r.logs.recs {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(rec.Message)
|
||||||
|
rec.Attrs(func(a slog.Attr) bool { fmt.Fprintf(&b, " %s=%v", a.Key, a.Value); return true })
|
||||||
|
line := b.String()
|
||||||
|
if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "Three Pages") || strings.Contains(line, "quick brown") {
|
||||||
|
t.Errorf("a restricted caller's PDF fetch logged %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.logs.mu.Unlock()
|
||||||
|
// The fleet does not see the restricted caller's cached text: it extracts its own copy.
|
||||||
|
if code, _, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL}); code != 200 || r.runs() != 2 {
|
||||||
|
t.Errorf("fleet after restricted: %d, %d extractions; want 200 and 2 (separate cache partitions)", code, r.runs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFScrape(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
o := newOrigin(t, "application/pdf", threePDF())
|
||||||
|
rec := postJSON(t, r.s, "/v2/scrape", r.fleet, map[string]any{"url": o.srv.URL, "formats": []string{"markdown"}})
|
||||||
|
var out struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || rec.Code != 200 || !out.Success {
|
||||||
|
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Data.Markdown, "--- page 2 ---") || out.Data.Metadata["numPages"] != 3.0 || out.Data.Metadata["title"] != "Three Pages" {
|
||||||
|
t.Errorf("scrape: markdown %q metadata %v", out.Data.Markdown, out.Data.Metadata)
|
||||||
|
}
|
||||||
|
if ct, _ := out.Data.Metadata["contentType"].(string); !strings.HasPrefix(ct, "application/pdf") {
|
||||||
|
t.Errorf("contentType = %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question on a document longer than PDFMaxQuestionChunks is refused with a pointer to grep
|
||||||
|
// and pages, before any model call.
|
||||||
|
func TestPDFQuestionTooLong(t *testing.T) {
|
||||||
|
r := newPDFRig(t)
|
||||||
|
r.s.PDFMaxQuestionChunks = 2
|
||||||
|
o := newOrigin(t, "application/pdf", longPDF(12))
|
||||||
|
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "question": "what is the method?"})
|
||||||
|
if msg, _ := out["error"].(string); code != 422 || !strings.Contains(msg, "too long") || !strings.Contains(msg, "grep") {
|
||||||
|
t.Errorf("long question: %d %v; want 422 … too long … grep", code, out)
|
||||||
|
}
|
||||||
|
if DefaultPDFMaxQuestionChunks != 200 {
|
||||||
|
t.Errorf("DefaultPDFMaxQuestionChunks = %d, want 200", DefaultPDFMaxQuestionChunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package clean_test
|
||||||
|
|
||||||
|
// P5a task 01: Kind decides which cleaner a fetched body gets, now including "pdf".
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKind(t *testing.T) {
|
||||||
|
pdf := []byte("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj << >> endobj\n")
|
||||||
|
junkThenPDF := append([]byte(strings.Repeat(" ", 900)), pdf...) // the spec allows leading junk
|
||||||
|
lateMagic := append([]byte(strings.Repeat("x", 2000)), pdf...) // past the first 1024 bytes
|
||||||
|
binary := []byte{0x50, 0x4b, 0x03, 0x04, 0x00, 0x00} // a zip
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, ct string
|
||||||
|
body []byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"pdf by type", "application/pdf", pdf, "pdf"},
|
||||||
|
{"pdf by type with params", "Application/PDF; qs=0.9", pdf, "pdf"},
|
||||||
|
{"x-pdf", "application/x-pdf", pdf, "pdf"},
|
||||||
|
{"pdf type, body not checked", "application/pdf", []byte("oops"), "pdf"},
|
||||||
|
{"octet-stream sniffed as pdf", "application/octet-stream", pdf, "pdf"},
|
||||||
|
{"binary/octet-stream sniffed as pdf", "binary/octet-stream", pdf, "pdf"},
|
||||||
|
{"no type sniffed as pdf", "", pdf, "pdf"},
|
||||||
|
{"leading junk within 1024 bytes", "application/octet-stream", junkThenPDF, "pdf"},
|
||||||
|
{"magic too late", "application/octet-stream", lateMagic, "other"},
|
||||||
|
{"octet-stream that is not a pdf", "application/octet-stream", binary, "other"},
|
||||||
|
{"html stays html even if it mentions %PDF-", "text/html; charset=utf-8", []byte("<html>%PDF-1.4</html>"), "html"},
|
||||||
|
{"json", "application/json", []byte(`{"a":1}`), "json"},
|
||||||
|
{"problem+json", "application/problem+json", []byte(`{}`), "json"},
|
||||||
|
{"text", "text/plain", []byte("hello"), "text"},
|
||||||
|
{"no type, html", "", []byte("<!doctype html><html></html>"), "html"},
|
||||||
|
{"no type, json", "", []byte(`[1,2]`), "json"},
|
||||||
|
{"no type, text", "", []byte("just words"), "text"},
|
||||||
|
{"zip", "application/zip", binary, "other"},
|
||||||
|
{"image", "image/png", []byte{0x89, 'P', 'N', 'G'}, "other"},
|
||||||
|
} {
|
||||||
|
if got := clean.Kind(tc.ct, tc.body); got != tc.want {
|
||||||
|
t.Errorf("%s: Kind(%q, …) = %q, want %q", tc.name, tc.ct, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaKind keeps answering for callers that have no body: the pdf types are "pdf" there too.
|
||||||
|
func TestMediaKindKnowsPDF(t *testing.T) {
|
||||||
|
for ct, want := range map[string]string{"application/pdf": "pdf", "application/x-pdf": "pdf", "text/html": "html", "": "sniff"} {
|
||||||
|
if got := clean.MediaKind(ct); got != want {
|
||||||
|
t.Errorf("MediaKind(%q) = %q, want %q", ct, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package dcache_test
|
||||||
|
|
||||||
|
// P5a task 03: the PDF document cache. One entry per (URL, caller class): how to revalidate it,
|
||||||
|
// its metadata (opaque JSON here) and the text of every extracted page.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openDB(t *testing.T, maxBytes int64) *dcache.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), maxBytes, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(pages ...string) dcache.PDFEntry {
|
||||||
|
return dcache.PDFEntry{
|
||||||
|
ContentHash: "sha256:abc",
|
||||||
|
ETag: `"v1"`,
|
||||||
|
LastModified: "Fri, 25 Sep 2026 12:00:00 GMT",
|
||||||
|
FetchedAt: time.Date(2026, 9, 25, 20, 0, 0, 0, time.UTC),
|
||||||
|
Meta: []byte(`{"pages":3,"title":"T","outline":[]}`),
|
||||||
|
Pages: pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFRoundTrip(t *testing.T) {
|
||||||
|
db := openDB(t, 0)
|
||||||
|
k := dcache.PDFKey("https://example.org/a.pdf", dcache.ClassFleet)
|
||||||
|
if _, ok := db.GetPDF(k); ok {
|
||||||
|
t.Fatal("hit on an empty cache")
|
||||||
|
}
|
||||||
|
want := entry("page one", "page two", "", "page four") // an empty page keeps its place
|
||||||
|
db.PutPDF(k, want)
|
||||||
|
got, ok := db.GetPDF(k)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("miss after put")
|
||||||
|
}
|
||||||
|
if got.ContentHash != want.ContentHash || got.ETag != want.ETag || got.LastModified != want.LastModified ||
|
||||||
|
!got.FetchedAt.Equal(want.FetchedAt) || string(got.Meta) != string(want.Meta) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
if len(got.Pages) != 4 || got.Pages[0] != "page one" || got.Pages[2] != "" || got.Pages[3] != "page four" {
|
||||||
|
t.Errorf("pages = %q", got.Pages)
|
||||||
|
}
|
||||||
|
// A second put replaces the whole entry, pages included.
|
||||||
|
db.PutPDF(k, entry("only"))
|
||||||
|
got, _ = db.GetPDF(k)
|
||||||
|
if len(got.Pages) != 1 || got.Pages[0] != "only" {
|
||||||
|
t.Errorf("after replace, pages = %q; want [only]", got.Pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFKeyPartitions(t *testing.T) {
|
||||||
|
db := openDB(t, 0)
|
||||||
|
u := "https://example.org/a.pdf"
|
||||||
|
fleet, restricted := dcache.PDFKey(u, dcache.ClassFleet), dcache.PDFKey(u, dcache.ClassRestricted)
|
||||||
|
if fleet == restricted {
|
||||||
|
t.Fatal("the same key for both caller classes")
|
||||||
|
}
|
||||||
|
db.PutPDF(restricted, entry("secret page"))
|
||||||
|
if _, ok := db.GetPDF(fleet); ok {
|
||||||
|
t.Error("a restricted caller's document is visible to the fleet")
|
||||||
|
}
|
||||||
|
if dcache.PDFKey(u, dcache.ClassFleet) != fleet || dcache.PDFKey("https://example.org/b.pdf", dcache.ClassFleet) == fleet {
|
||||||
|
t.Error("PDFKey must be deterministic and differ per URL")
|
||||||
|
}
|
||||||
|
if strings.Contains(fleet, "example.org") {
|
||||||
|
t.Error("the key must not carry the URL in the clear (it is hashed like the other keys)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFTouch(t *testing.T) {
|
||||||
|
db := openDB(t, 0)
|
||||||
|
k := dcache.PDFKey("https://example.org/a.pdf", dcache.ClassFleet)
|
||||||
|
db.PutPDF(k, entry("x"))
|
||||||
|
later := time.Date(2026, 9, 26, 20, 0, 0, 0, time.UTC)
|
||||||
|
db.TouchPDF(k, later) // after a 304: the text is unchanged, only its freshness moves
|
||||||
|
got, _ := db.GetPDF(k)
|
||||||
|
if !got.FetchedAt.Equal(later) || len(got.Pages) != 1 {
|
||||||
|
t.Errorf("after touch: %+v", got)
|
||||||
|
}
|
||||||
|
db.TouchPDF(dcache.PDFKey("https://nope", dcache.ClassFleet), later) // a miss is a no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documents count toward maxBytes and are evicted whole, oldest use first.
|
||||||
|
func TestPDFEviction(t *testing.T) {
|
||||||
|
db := openDB(t, 300<<10) // 300 KiB
|
||||||
|
big := strings.Repeat("z", 50<<10)
|
||||||
|
var keys []string
|
||||||
|
for i := 0; i < 10; i++ { // 10 × 2 pages × 50 KiB = 1 MiB of text
|
||||||
|
k := dcache.PDFKey("https://example.org/"+string(rune('a'+i))+".pdf", dcache.ClassFleet)
|
||||||
|
keys = append(keys, k)
|
||||||
|
db.PutPDF(k, entry(big, big))
|
||||||
|
}
|
||||||
|
if db.Size() > 300<<10 {
|
||||||
|
t.Errorf("size %d after puts, want ≤ maxBytes %d", db.Size(), 300<<10)
|
||||||
|
}
|
||||||
|
if _, ok := db.GetPDF(keys[9]); !ok {
|
||||||
|
t.Error("the newest document was evicted")
|
||||||
|
}
|
||||||
|
if _, ok := db.GetPDF(keys[0]); ok {
|
||||||
|
t.Error("the oldest document survived; eviction did not reach PDF entries")
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if e, ok := db.GetPDF(k); ok && len(e.Pages) != 2 {
|
||||||
|
t.Errorf("a partly evicted document: %d pages", len(e.Pages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFNilDB(t *testing.T) {
|
||||||
|
var db *dcache.DB
|
||||||
|
db.PutPDF("k", entry("x"))
|
||||||
|
db.TouchPDF("k", time.Now())
|
||||||
|
if _, ok := db.GetPDF("k"); ok {
|
||||||
|
t.Error("nil DB returned a hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package digest
|
||||||
|
|
||||||
|
// P5a task 07: a digest of a PDF cites pages. The page markers come from internal/pdfdoc; the
|
||||||
|
// prompt tells the model to use them, and the prompt version moves so cached digests made with
|
||||||
|
// the old prompt are not served.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPromptCitesPages(t *testing.T) {
|
||||||
|
if !strings.Contains(systemPrompt, "--- page N ---") || !strings.Contains(systemPrompt, "(p. N)") {
|
||||||
|
t.Errorf("system prompt must tell the model to cite pages from --- page N --- markers:\n%s", systemPrompt)
|
||||||
|
}
|
||||||
|
if PromptVersion == "2" {
|
||||||
|
t.Error("PromptVersion must change with the prompt (was \"2\")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page markers survive chunking whole: none is cut across two chunks. (This holds for today's
|
||||||
|
// Split, whose paragraph boundaries fall around each marker; the test keeps it that way.)
|
||||||
|
func TestSplitKeepsPageMarkers(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
for p := 1; p <= 40; p++ {
|
||||||
|
b.WriteString("--- page " + strconv.Itoa(p) + " ---\n\n")
|
||||||
|
b.WriteString(strings.Repeat("word ", 600))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
marker := regexp.MustCompile(`--- page \d+ ---`)
|
||||||
|
n := 0
|
||||||
|
for _, c := range Split(b.String(), ChunkTokens) {
|
||||||
|
n += len(marker.FindAllString(c, -1))
|
||||||
|
}
|
||||||
|
if n != 40 {
|
||||||
|
t.Errorf("%d whole page markers across the chunks, want 40", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package fetch_test
|
||||||
|
|
||||||
|
// P5a task 01: a PDF response gets a larger body cap and a longer deadline than a web page, and
|
||||||
|
// FetchIf revalidates a cached document with a conditional GET.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loopbackFetcher() *fetch.Fetcher {
|
||||||
|
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
||||||
|
return &fetch.Fetcher{Guard: g, UA: "sift-test"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(prefix string, n int) []byte {
|
||||||
|
b := bytes.Repeat([]byte("x"), n)
|
||||||
|
copy(b, prefix)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(t *testing.T, ct string, b []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ct != "" {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return ts.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(err error) int {
|
||||||
|
var fe *fetch.Error
|
||||||
|
if errors.As(err, &fe) {
|
||||||
|
return fe.Status
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFBodyCap(t *testing.T) {
|
||||||
|
six := 6 << 20 // over DefaultMaxBody (5 MiB), under DefaultPDFMaxBody (100 MiB)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, ct string
|
||||||
|
b []byte
|
||||||
|
want int // 0 = ok
|
||||||
|
}{
|
||||||
|
{"pdf by type", "application/pdf", body("%PDF-1.7\n", six), 0},
|
||||||
|
{"pdf by sniffing octet-stream", "application/octet-stream", body("%PDF-1.7\n", six), 0},
|
||||||
|
{"pdf with no type", "", body("%PDF-1.7\n", six), 0},
|
||||||
|
{"html keeps the 5 MiB cap", "text/html", body("<html>", six), 413},
|
||||||
|
{"octet-stream that is not a pdf keeps the cap", "application/octet-stream", body("PK\x03\x04", six), 413},
|
||||||
|
} {
|
||||||
|
f := loopbackFetcher()
|
||||||
|
res, err := f.Fetch(context.Background(), serve(t, tc.ct, tc.b))
|
||||||
|
if tc.want == 0 {
|
||||||
|
if err != nil || res == nil || len(res.Body) != six {
|
||||||
|
t.Errorf("%s: err %v, body %d bytes; want the whole %d", tc.name, err, lenBody(res), six)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status(err) != tc.want {
|
||||||
|
t.Errorf("%s: err %v (status %d), want %d", tc.name, err, status(err), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lenBody(r *fetch.Resource) int {
|
||||||
|
if r == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return len(r.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFMaxBodyIsConfigurable(t *testing.T) {
|
||||||
|
f := loopbackFetcher()
|
||||||
|
f.PDFMaxBody = 1 << 20
|
||||||
|
_, err := f.Fetch(context.Background(), serve(t, "application/pdf", body("%PDF-1.7\n", 2<<20)))
|
||||||
|
if status(err) != 413 || !strings.Contains(err.Error(), "too large") {
|
||||||
|
t.Errorf("2 MiB pdf over a 1 MiB PDFMaxBody: %v, want 413 too large", err)
|
||||||
|
}
|
||||||
|
if fetch.DefaultPDFMaxBody != 100<<20 || fetch.DefaultPDFTimeout != 120*time.Second {
|
||||||
|
t.Errorf("defaults = %d, %v; want 100 MiB, 120s", fetch.DefaultPDFMaxBody, fetch.DefaultPDFTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slow sends the headers, then the body in pieces over about 600 ms.
|
||||||
|
func slow(t *testing.T, ct string, first string) string {
|
||||||
|
t.Helper()
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
_, _ = w.Write([]byte(first))
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
_, _ = w.Write(bytes.Repeat([]byte("y"), 1024))
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return ts.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// The web-page deadline (Timeout) still applies to HTML; a PDF gets PDFTimeout.
|
||||||
|
func TestPDFDeadline(t *testing.T) {
|
||||||
|
f := loopbackFetcher()
|
||||||
|
f.Timeout = 250 * time.Millisecond
|
||||||
|
f.PDFTimeout = 3 * time.Second
|
||||||
|
if _, err := f.Fetch(context.Background(), slow(t, "application/pdf", "%PDF-1.7\n")); err != nil {
|
||||||
|
t.Errorf("slow pdf under PDFTimeout: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Fetch(context.Background(), slow(t, "application/octet-stream", "%PDF-1.7\n")); err != nil {
|
||||||
|
t.Errorf("slow sniffed pdf under PDFTimeout: %v", err)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := f.Fetch(context.Background(), slow(t, "text/html", "<html>")); err == nil {
|
||||||
|
t.Errorf("slow html past Timeout succeeded; want an error")
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 2*time.Second {
|
||||||
|
t.Errorf("slow html took %v to fail; the 250 ms deadline was not applied", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchIf(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, r.Header.Get("If-None-Match")+"|"+r.Header.Get("If-Modified-Since"))
|
||||||
|
mu.Unlock()
|
||||||
|
if r.Header.Get("If-None-Match") == `"v1"` {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("ETag", `"v2"`)
|
||||||
|
w.Header().Set("Last-Modified", "Fri, 25 Sep 2026 12:00:00 GMT")
|
||||||
|
_, _ = w.Write([]byte("%PDF-1.7\nnew"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
f := loopbackFetcher()
|
||||||
|
|
||||||
|
res, err := f.FetchIf(context.Background(), ts.URL, `"v1"`, "")
|
||||||
|
if err != nil || res == nil || res.Status != http.StatusNotModified || len(res.Body) != 0 {
|
||||||
|
t.Fatalf("matching ETag: res %+v err %v; want status 304, empty body, nil error", res, err)
|
||||||
|
}
|
||||||
|
res, err = f.FetchIf(context.Background(), ts.URL, `"old"`, "Thu, 24 Sep 2026 12:00:00 GMT")
|
||||||
|
if err != nil || res.Status != 200 || string(res.Body) != "%PDF-1.7\nnew" || res.Header.Get("ETag") != `"v2"` {
|
||||||
|
t.Fatalf("changed document: res %+v err %v; want 200 with the new body and ETag", res, err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(got) != 2 || got[0] != `"v1"|` || got[1] != `"old"|Thu, 24 Sep 2026 12:00:00 GMT` {
|
||||||
|
t.Errorf("validators sent: %q", got)
|
||||||
|
}
|
||||||
|
// With no validators FetchIf is a plain Fetch.
|
||||||
|
if _, err := f.FetchIf(context.Background(), ts.URL, "", ""); err != nil {
|
||||||
|
t.Errorf("FetchIf with no validators: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// P5a task 07: the sift_pdf_* series.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zeroSrc struct{}
|
||||||
|
|
||||||
|
func (zeroSrc) Snapshot() Snapshot { return Snapshot{} }
|
||||||
|
|
||||||
|
func TestPDFSeries(t *testing.T) {
|
||||||
|
s := New(zeroSrc{})
|
||||||
|
s.ObservePDF("ok", 1500*time.Millisecond, 212)
|
||||||
|
s.ObservePDF("ok", 200*time.Millisecond, 3)
|
||||||
|
s.ObservePDF("no_text", 50*time.Millisecond, 0)
|
||||||
|
s.ObservePDF("blocked: evil.example", time.Millisecond, 0) // raw text never becomes a label
|
||||||
|
s.ObservePDFWait(2 * time.Second)
|
||||||
|
s.PDFCacheHit()
|
||||||
|
s.PDFCacheHit()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||||
|
req.RemoteAddr, req.Host = "127.0.0.1:9", "127.0.0.1:8890"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
body, _ := io.ReadAll(rec.Body)
|
||||||
|
out := string(body)
|
||||||
|
for _, want := range []string{
|
||||||
|
`sift_pdf_extract_total{outcome="ok"} 2`,
|
||||||
|
`sift_pdf_extract_total{outcome="no_text"} 1`,
|
||||||
|
`sift_pdf_extract_total{outcome="other"} 1`,
|
||||||
|
`sift_pdf_pages_total 215`,
|
||||||
|
`sift_pdf_cache_hits_total 2`,
|
||||||
|
`sift_pdf_extract_seconds_count 4`,
|
||||||
|
`sift_pdf_pool_wait_seconds_count 1`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("metrics lack %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "evil.example") {
|
||||||
|
t.Error("a raw outcome string leaked into a label")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package pdfdoc_test
|
||||||
|
|
||||||
|
// P5a task 05: rendering an extracted document for an agent — the whole text with page markers,
|
||||||
|
// the map for a long document, a page range, and grep.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdfdoc"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
||||||
|
)
|
||||||
|
|
||||||
|
func small() *pdftext.Doc {
|
||||||
|
return &pdftext.Doc{
|
||||||
|
Meta: pdftext.Meta{Pages: 3, Title: "Three Pages", Outline: []pdftext.OutlineItem{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "2.1 Den", Page: 2, Level: 2}}},
|
||||||
|
Text: []string{"Chapter one begins here.\nSecond line.\n", " The quick brown fox is on page two.\nIt jumps.", "Last page."},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// big returns n pages of about 1,000 characters each, every line naming its page.
|
||||||
|
func big(n int) *pdftext.Doc {
|
||||||
|
d := &pdftext.Doc{Meta: pdftext.Meta{Pages: n, Title: "Long Thesis", Outline: []pdftext.OutlineItem{{Title: "1 Introduction", Page: 1, Level: 1}, {Title: "2 Method", Page: 12, Level: 1}, {Title: "2.3 Setup", Page: 15, Level: 2}}}}
|
||||||
|
for p := 1; p <= n; p++ {
|
||||||
|
var lines []string
|
||||||
|
for l := 1; l <= 12; l++ {
|
||||||
|
lines = append(lines, fmt.Sprintf("page %d line %d lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod", p, l))
|
||||||
|
}
|
||||||
|
if p == 37 {
|
||||||
|
lines[4] = "the Needle is here on page 37"
|
||||||
|
}
|
||||||
|
d.Text = append(d.Text, strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFull(t *testing.T) {
|
||||||
|
want := "--- page 1 ---\n\nChapter one begins here.\nSecond line.\n\n--- page 2 ---\n\nThe quick brown fox is on page two.\nIt jumps.\n\n--- page 3 ---\n\nLast page."
|
||||||
|
if got := pdfdoc.Full(small()); got != want {
|
||||||
|
t.Errorf("Full =\n%q\nwant\n%q", got, want)
|
||||||
|
}
|
||||||
|
if pdfdoc.Marker(12) != "--- page 12 ---" {
|
||||||
|
t.Errorf("Marker(12) = %q", pdfdoc.Marker(12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWholeDocumentWhenItFits(t *testing.T) {
|
||||||
|
r, err := pdfdoc.Render(small(), pdfdoc.Options{OutCap: 10000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Map || r.First != 1 || r.Last != 3 || r.Content != pdfdoc.Full(small()) {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapForALongDocument(t *testing.T) {
|
||||||
|
d := big(60) // ~60k characters
|
||||||
|
r, err := pdfdoc.Render(d, pdfdoc.Options{OutCap: 20000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.Map || r.First != 1 || r.Last < 1 {
|
||||||
|
t.Fatalf("got Map=%v First=%d Last=%d, want a map from page 1", r.Map, r.First, r.Last)
|
||||||
|
}
|
||||||
|
c := r.Content
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Long Thesis\n",
|
||||||
|
"60 pages · ~",
|
||||||
|
"## Outline\n",
|
||||||
|
"- 1 Introduction (p. 1)\n",
|
||||||
|
"- 2 Method (p. 12)\n",
|
||||||
|
" - 2.3 Setup (p. 15)\n", // level 2 is indented two spaces
|
||||||
|
`pages="a-b"`,
|
||||||
|
`grep="pattern"`,
|
||||||
|
"--- page 1 ---",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("map lacks %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(c) > 20000 {
|
||||||
|
t.Errorf("map is %d characters, over OutCap 20000", utf8.RuneCountInString(c))
|
||||||
|
}
|
||||||
|
// Leading pages take at most a quarter of the cap, and only whole pages.
|
||||||
|
if strings.Contains(c, fmt.Sprintf("--- page %d ---", r.Last+1)) {
|
||||||
|
t.Errorf("content has page %d past Last %d", r.Last+1, r.Last)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, fmt.Sprintf("page %d line 12", r.Last)) {
|
||||||
|
t.Errorf("the last page shown (%d) is cut; pages in a map are whole", r.Last)
|
||||||
|
}
|
||||||
|
idx := strings.Index(c, "--- page 1 ---")
|
||||||
|
if pagesPart := c[idx:]; utf8.RuneCountInString(pagesPart) > 20000/4 {
|
||||||
|
t.Errorf("leading pages take %d characters, over OutCap/4", utf8.RuneCountInString(pagesPart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapNotesPartialExtraction(t *testing.T) {
|
||||||
|
d := big(30)
|
||||||
|
d.Pages = 2500 // the child stopped at its page cap
|
||||||
|
r, _ := pdfdoc.Render(d, pdfdoc.Options{OutCap: 5000})
|
||||||
|
if !strings.Contains(r.Content, "2500 pages") || !strings.Contains(r.Content, "pages 1-30 extracted") {
|
||||||
|
t.Errorf("map must say how much was extracted:\n%s", r.Content[:min(600, len(r.Content))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUntitledAndNoOutline(t *testing.T) {
|
||||||
|
d := big(40)
|
||||||
|
d.Title, d.Outline = "", nil
|
||||||
|
r, _ := pdfdoc.Render(d, pdfdoc.Options{OutCap: 5000})
|
||||||
|
if !strings.HasPrefix(r.Content, "# Untitled PDF\n") || strings.Contains(r.Content, "## Outline") {
|
||||||
|
t.Errorf("untitled, outline-less map:\n%s", r.Content[:min(400, len(r.Content))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPages(t *testing.T) {
|
||||||
|
d := big(60)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
spec string
|
||||||
|
first, last int
|
||||||
|
}{
|
||||||
|
{"2-3", 2, 3},
|
||||||
|
{"7", 7, 7},
|
||||||
|
{" 58- ", 58, 60},
|
||||||
|
{"60-60", 60, 60},
|
||||||
|
} {
|
||||||
|
r, err := pdfdoc.Render(d, pdfdoc.Options{Pages: tc.spec, OutCap: 200000})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q: %v", tc.spec, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Map || r.First != tc.first || r.Last != tc.last {
|
||||||
|
t.Errorf("%q: First %d Last %d Map %v, want %d-%d", tc.spec, r.First, r.Last, r.Map, tc.first, tc.last)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.Content, pdfdoc.Marker(tc.first)+"\n\n") || strings.Contains(r.Content, pdfdoc.Marker(tc.last+1)) || (tc.first > 1 && strings.Contains(r.Content, pdfdoc.Marker(tc.first-1)+"\n")) {
|
||||||
|
t.Errorf("%q: wrong pages in content: %.120q…", tc.spec, r.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"0-1", "5-4", "61", "999", "a-b", "1-2-3", "", "-5"} {
|
||||||
|
if bad == "" {
|
||||||
|
continue // empty means "no range", tested above
|
||||||
|
}
|
||||||
|
_, err := pdfdoc.Render(d, pdfdoc.Options{Pages: bad, OutCap: 200000})
|
||||||
|
if !errors.Is(err, pdfdoc.ErrBadPages) {
|
||||||
|
t.Errorf("%q: %v, want ErrBadPages", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := pdfdoc.Render(d, pdfdoc.Options{Pages: "61", OutCap: 200000})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pages out of range (1-60)") {
|
||||||
|
t.Errorf("out-of-range message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A range is at most 100 pages and at most OutCap characters, cut at a whole page with a note
|
||||||
|
// that says how to continue.
|
||||||
|
func TestPagesAreCapped(t *testing.T) {
|
||||||
|
d := big(300)
|
||||||
|
r, err := pdfdoc.Render(d, pdfdoc.Options{Pages: "1-300", OutCap: 1 << 30})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Last != 100 || !strings.Contains(r.Note, `pages="101-300"`) {
|
||||||
|
t.Errorf("1-300: Last %d note %q; want 100 and a pointer to 101-300", r.Last, r.Note)
|
||||||
|
}
|
||||||
|
r, _ = pdfdoc.Render(d, pdfdoc.Options{Pages: "10-40", OutCap: 5000})
|
||||||
|
if utf8.RuneCountInString(r.Content) > 5000 || r.First != 10 || r.Last < 10 || r.Last >= 40 {
|
||||||
|
t.Errorf("10-40 under a 5000 cap: First %d Last %d, %d chars", r.First, r.Last, utf8.RuneCountInString(r.Content))
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Note, fmt.Sprintf(`pages="%d-40"`, r.Last+1)) {
|
||||||
|
t.Errorf("note %q must point at the rest", r.Note)
|
||||||
|
}
|
||||||
|
// One page bigger than the cap is truncated rather than dropped.
|
||||||
|
huge := &pdftext.Doc{Meta: pdftext.Meta{Pages: 1}, Text: []string{strings.Repeat("w ", 10000)}}
|
||||||
|
r, _ = pdfdoc.Render(huge, pdfdoc.Options{Pages: "1", OutCap: 1000})
|
||||||
|
if r.First != 1 || r.Last != 1 || utf8.RuneCountInString(r.Content) > 1000 || r.Note == "" {
|
||||||
|
t.Errorf("oversized single page: %+v (%d chars)", r, utf8.RuneCountInString(r.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrep(t *testing.T) {
|
||||||
|
d := big(60)
|
||||||
|
r, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "needle", OutCap: 200000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.GrepHits != 1 || r.First != 0 || r.Map {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`grep "needle": 1 match`,
|
||||||
|
"p. 37- page 37 line 4",
|
||||||
|
"p. 37: the Needle is here on page 37",
|
||||||
|
"p. 37- page 37 line 6",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(r.Content, want) {
|
||||||
|
t.Errorf("grep output lacks %q:\n%s", want, r.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Smart case: an upper-case letter makes the pattern case-sensitive.
|
||||||
|
if r, _ := pdfdoc.Render(d, pdfdoc.Options{Grep: "NEEDLE", OutCap: 200000}); r.GrepHits != 0 {
|
||||||
|
t.Errorf("NEEDLE matched %d times, want 0 (case-sensitive)", r.GrepHits)
|
||||||
|
}
|
||||||
|
if r, _ := pdfdoc.Render(d, pdfdoc.Options{Grep: "Needle", OutCap: 200000}); r.GrepHits != 1 {
|
||||||
|
t.Errorf("Needle matched %d times, want 1", r.GrepHits)
|
||||||
|
}
|
||||||
|
// No match is an answer, not an error.
|
||||||
|
r, err = pdfdoc.Render(d, pdfdoc.Options{Grep: "zebra", OutCap: 200000})
|
||||||
|
if err != nil || r.GrepHits != 0 || !strings.Contains(r.Content, `grep "zebra": no matches`) {
|
||||||
|
t.Errorf("no match: %+v %v", r, err)
|
||||||
|
}
|
||||||
|
// Within a page range.
|
||||||
|
r, _ = pdfdoc.Render(d, pdfdoc.Options{Grep: "line 3 ", Pages: "5-6", OutCap: 200000})
|
||||||
|
if r.GrepHits != 2 || strings.Contains(r.Content, "p. 7") {
|
||||||
|
t.Errorf("grep in 5-6: %d hits\n%s", r.GrepHits, r.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrepLimits(t *testing.T) {
|
||||||
|
d := big(200)
|
||||||
|
r, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "lorem", OutCap: 1 << 30}) // 2,399 matches: every line but the needle
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.GrepHits != 2399 || strings.Count(r.Content, ": page ") != 100 || !strings.Contains(r.Content, "showing the first 100") {
|
||||||
|
t.Errorf("hits %d, shown %d", r.GrepHits, strings.Count(r.Content, ": page "))
|
||||||
|
}
|
||||||
|
if _, err := pdfdoc.Render(d, pdfdoc.Options{Grep: strings.Repeat("a", 201), OutCap: 1000}); !errors.Is(err, pdfdoc.ErrBadGrep) {
|
||||||
|
t.Errorf("201-byte pattern: %v, want ErrBadGrep", err)
|
||||||
|
}
|
||||||
|
if _, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "(unclosed", OutCap: 1000}); !errors.Is(err, pdfdoc.ErrBadGrep) {
|
||||||
|
t.Errorf("bad regexp: %v, want ErrBadGrep", err)
|
||||||
|
}
|
||||||
|
// RE2 is linear: the classic catastrophic pattern over a 1 MB page finishes quickly.
|
||||||
|
evil := &pdftext.Doc{Meta: pdftext.Meta{Pages: 1}, Text: []string{strings.Repeat("a", 1<<20) + "!"}}
|
||||||
|
start := time.Now()
|
||||||
|
_, _ = pdfdoc.Render(evil, pdfdoc.Options{Grep: "(a+)+$", OutCap: 1000})
|
||||||
|
if d := time.Since(start); d > time.Second {
|
||||||
|
t.Errorf("(a+)+$ took %v", d)
|
||||||
|
}
|
||||||
|
// Output stays under the cap.
|
||||||
|
r, _ = pdfdoc.Render(d, pdfdoc.Options{Grep: "lorem", OutCap: 3000})
|
||||||
|
if utf8.RuneCountInString(r.Content) > 3000 {
|
||||||
|
t.Errorf("grep output %d characters over a 3000 cap", utf8.RuneCountInString(r.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package pdfpool_test
|
||||||
|
|
||||||
|
// P5a task 04: the extraction pool. A few slots, a short queue, single-flight per key, and at
|
||||||
|
// most one job in flight per caller.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func waitUntil(t *testing.T, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for !cond() {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("condition not reached within 3 s")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleFlight(t *testing.T) {
|
||||||
|
p := pdfpool.New(2, 4, time.Second)
|
||||||
|
var calls atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
fn := func(ctx context.Context) (any, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
<-release
|
||||||
|
return "text", nil
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan any, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
v, err := p.Do(context.Background(), "doc-1", "caller-"+string(rune('a'+i)), fn)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
results <- v
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
waitUntil(t, func() bool { return calls.Load() == 1 && p.Waiting("doc-1") == 4 })
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
for v := range results {
|
||||||
|
if v != "text" {
|
||||||
|
t.Errorf("a sharer got %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := calls.Load(); n != 1 {
|
||||||
|
t.Errorf("fn ran %d times for one key, want 1", n)
|
||||||
|
}
|
||||||
|
// Nothing is cached: the next call runs fn again.
|
||||||
|
_, _ = p.Do(context.Background(), "doc-1", "x", func(ctx context.Context) (any, error) { calls.Add(1); return nil, nil })
|
||||||
|
if n := calls.Load(); n != 2 {
|
||||||
|
t.Errorf("after the flight ended, fn ran %d times in total, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two slots and four waiting places: the seventh distinct job is refused at once.
|
||||||
|
func TestSlotsAndQueue(t *testing.T) {
|
||||||
|
p := pdfpool.New(2, 4, 5*time.Second)
|
||||||
|
release := make(chan struct{})
|
||||||
|
var running atomic.Int32
|
||||||
|
block := func(ctx context.Context) (any, error) {
|
||||||
|
running.Add(1)
|
||||||
|
defer running.Add(-1)
|
||||||
|
<-release
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := p.Do(context.Background(), "doc-"+string(rune('a'+i)), "caller-"+string(rune('a'+i)), block); err != nil {
|
||||||
|
t.Errorf("job %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
waitUntil(t, func() bool { return running.Load() == 2 && p.Queued() == 4 })
|
||||||
|
start := time.Now()
|
||||||
|
_, err := p.Do(context.Background(), "doc-z", "caller-z", block)
|
||||||
|
if !errors.Is(err, pdfpool.ErrBusy) {
|
||||||
|
t.Errorf("seventh job: %v, want ErrBusy", err)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 200*time.Millisecond {
|
||||||
|
t.Errorf("a full queue refused after %v; it must refuse at once", d)
|
||||||
|
}
|
||||||
|
if running.Load() > 2 {
|
||||||
|
t.Errorf("%d jobs ran at once, want at most 2", running.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A queued job that waits longer than Wait is refused with ErrBusy and gives up its place.
|
||||||
|
func TestQueueWait(t *testing.T) {
|
||||||
|
p := pdfpool.New(1, 1, 150*time.Millisecond)
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
go func() {
|
||||||
|
_, _ = p.Do(context.Background(), "a", "ca", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||||
|
start := time.Now()
|
||||||
|
_, err := p.Do(context.Background(), "b", "cb", func(ctx context.Context) (any, error) { return nil, nil })
|
||||||
|
if !errors.Is(err, pdfpool.ErrBusy) {
|
||||||
|
t.Fatalf("waiter: %v, want ErrBusy", err)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d < 100*time.Millisecond || d > time.Second {
|
||||||
|
t.Errorf("waiter refused after %v, want about 150 ms", d)
|
||||||
|
}
|
||||||
|
if p.Queued() != 0 {
|
||||||
|
t.Errorf("queued = %d after the waiter gave up, want 0", p.Queued())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerBusy(t *testing.T) {
|
||||||
|
p := pdfpool.New(2, 4, time.Second)
|
||||||
|
release := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_, _ = p.Do(context.Background(), "doc-1", "straylight", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||||
|
if _, err := p.Do(context.Background(), "doc-2", "straylight", func(ctx context.Context) (any, error) { return nil, nil }); !errors.Is(err, pdfpool.ErrCallerBusy) {
|
||||||
|
t.Errorf("second job from the same caller: %v, want ErrCallerBusy", err)
|
||||||
|
}
|
||||||
|
// The same caller asking for the same document again joins the flight instead.
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := p.Do(context.Background(), "doc-1", "straylight", func(ctx context.Context) (any, error) { return nil, nil })
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Waiting("doc-1") == 1 })
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Errorf("joining its own flight: %v", err)
|
||||||
|
}
|
||||||
|
// Once the job is done the caller may start another.
|
||||||
|
if _, err := p.Do(context.Background(), "doc-2", "straylight", func(ctx context.Context) (any, error) { return nil, nil }); err != nil {
|
||||||
|
t.Errorf("after the first job ended: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorsAndPanicsReachEverySharer(t *testing.T) {
|
||||||
|
p := pdfpool.New(2, 4, time.Second)
|
||||||
|
boom := errors.New("boom")
|
||||||
|
if _, err := p.Do(context.Background(), "k", "c", func(ctx context.Context) (any, error) { return nil, boom }); !errors.Is(err, boom) {
|
||||||
|
t.Errorf("fn error: %v", err)
|
||||||
|
}
|
||||||
|
_, err := p.Do(context.Background(), "k", "c", func(ctx context.Context) (any, error) { panic("kaboom") })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a panicking fn returned no error")
|
||||||
|
}
|
||||||
|
if p.Running() != 0 {
|
||||||
|
t.Errorf("running = %d after a panic, want 0 (the slot leaked)", p.Running())
|
||||||
|
}
|
||||||
|
if _, err := p.Do(context.Background(), "k2", "c", func(ctx context.Context) (any, error) { return 1, nil }); err != nil {
|
||||||
|
t.Errorf("the caller is still marked busy after its job panicked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextCancelWhileQueued(t *testing.T) {
|
||||||
|
p := pdfpool.New(1, 2, 5*time.Second)
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
go func() {
|
||||||
|
_, _ = p.Do(context.Background(), "a", "ca", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := p.Do(ctx, "b", "cb", func(ctx context.Context) (any, error) { return nil, nil })
|
||||||
|
errc <- err
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Queued() == 1 })
|
||||||
|
cancel()
|
||||||
|
if err := <-errc; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("cancelled waiter: %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if p.Queued() != 0 {
|
||||||
|
t.Errorf("queued = %d after cancel, want 0", p.Queued())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The job's own context is not the first caller's: if the caller that started a shared flight
|
||||||
|
// goes away, the sharers still get the result.
|
||||||
|
func TestFlightOutlivesItsStarter(t *testing.T) {
|
||||||
|
p := pdfpool.New(1, 2, 5*time.Second)
|
||||||
|
release := make(chan struct{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
jobCtxErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, _ = p.Do(ctx, "doc", "first", func(jctx context.Context) (any, error) {
|
||||||
|
<-release
|
||||||
|
jobCtxErr <- jctx.Err()
|
||||||
|
return "done", nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||||
|
res := make(chan any, 1)
|
||||||
|
go func() {
|
||||||
|
v, _ := p.Do(context.Background(), "doc", "second", nil)
|
||||||
|
res <- v
|
||||||
|
}()
|
||||||
|
waitUntil(t, func() bool { return p.Waiting("doc") == 1 })
|
||||||
|
cancel()
|
||||||
|
close(release)
|
||||||
|
if v := <-res; v != "done" {
|
||||||
|
t.Errorf("sharer got %v after the starter left, want done", v)
|
||||||
|
}
|
||||||
|
if e := <-jobCtxErr; e != nil {
|
||||||
|
t.Errorf("the job's context was cancelled with its starter: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Package pdftest builds small, deterministic PDFs for tests: text pages in Helvetica, an optional
|
||||||
|
// outline (bookmarks), and image-only ("scanned") pages. It exists so the P5a tests need no binary
|
||||||
|
// fixtures from elsewhere. It is test support: nothing outside _test.go files imports it.
|
||||||
|
package pdftest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outline is one bookmark: a title that points at a 1-based page, nested at Level (1 = top).
|
||||||
|
// Build supports one level; Level is kept so tests can state what they expect back.
|
||||||
|
type Outline struct {
|
||||||
|
Title string
|
||||||
|
Page int
|
||||||
|
Level int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc describes a PDF to build. Pages holds each page's text; a page's lines are split on "\n"
|
||||||
|
// and set one per line (long lines are not wrapped: callers keep them under ~90 characters).
|
||||||
|
// Scanned pages (1-based) carry a small image and no text at all.
|
||||||
|
type Doc struct {
|
||||||
|
Title string
|
||||||
|
Pages []string
|
||||||
|
Outline []Outline
|
||||||
|
Scanned []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the PDF bytes for d. It panics on a Doc it cannot express (an outline entry
|
||||||
|
// pointing past the last page), since that is a bug in the test that built it.
|
||||||
|
func Build(d Doc) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
var offsets []int
|
||||||
|
obj := func(body string) int {
|
||||||
|
offsets = append(offsets, b.Len())
|
||||||
|
n := len(offsets)
|
||||||
|
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", n, body)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
b.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||||
|
|
||||||
|
// Object numbers are fixed by position: 1 catalog, 2 pages, 3 font, 4 image, 5 info, then
|
||||||
|
// two per page (page, content), then the outline.
|
||||||
|
nPages := len(d.Pages)
|
||||||
|
firstPage := 6
|
||||||
|
pageObj := func(i int) int { return firstPage + 2*i }
|
||||||
|
outlineRoot := firstPage + 2*nPages
|
||||||
|
scanned := map[int]bool{}
|
||||||
|
for _, p := range d.Scanned {
|
||||||
|
scanned[p] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := "<< /Type /Catalog /Pages 2 0 R"
|
||||||
|
if len(d.Outline) > 0 {
|
||||||
|
catalog += fmt.Sprintf(" /Outlines %d 0 R /PageMode /UseOutlines", outlineRoot)
|
||||||
|
}
|
||||||
|
obj(catalog + " >>")
|
||||||
|
var kids []string
|
||||||
|
for i := range d.Pages {
|
||||||
|
kids = append(kids, fmt.Sprintf("%d 0 R", pageObj(i)))
|
||||||
|
}
|
||||||
|
obj(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), nPages))
|
||||||
|
obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||||
|
img := []byte{0x00, 0x80, 0x80, 0x00} // 2x2 grey pixels
|
||||||
|
obj(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length %d >>\nstream\n%s\nendstream", len(img), img))
|
||||||
|
obj(fmt.Sprintf("<< /Title (%s) /Producer (sift pdftest) >>", esc(d.Title)))
|
||||||
|
|
||||||
|
for i, text := range d.Pages {
|
||||||
|
var content string
|
||||||
|
if scanned[i+1] {
|
||||||
|
content = "q 400 0 0 500 100 150 cm /Im1 Do Q"
|
||||||
|
} else {
|
||||||
|
var c strings.Builder
|
||||||
|
c.WriteString("BT /F1 11 Tf 13 TL 60 760 Td\n")
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
fmt.Fprintf(&c, "(%s) Tj T*\n", esc(line))
|
||||||
|
}
|
||||||
|
c.WriteString("ET")
|
||||||
|
content = c.String()
|
||||||
|
}
|
||||||
|
obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents %d 0 R >>", pageObj(i)+1))
|
||||||
|
obj(fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(d.Outline) > 0 {
|
||||||
|
first := outlineRoot + 1
|
||||||
|
last := outlineRoot + len(d.Outline)
|
||||||
|
obj(fmt.Sprintf("<< /Type /Outlines /First %d 0 R /Last %d 0 R /Count %d >>", first, last, len(d.Outline)))
|
||||||
|
for i, o := range d.Outline {
|
||||||
|
if o.Page < 1 || o.Page > nPages {
|
||||||
|
panic(fmt.Sprintf("pdftest: outline %q points at page %d of %d", o.Title, o.Page, nPages))
|
||||||
|
}
|
||||||
|
n := first + i
|
||||||
|
item := fmt.Sprintf("<< /Title (%s) /Parent %d 0 R /Dest [%d 0 R /XYZ null null null]", esc(o.Title), outlineRoot, pageObj(o.Page-1))
|
||||||
|
if i > 0 {
|
||||||
|
item += fmt.Sprintf(" /Prev %d 0 R", n-1)
|
||||||
|
}
|
||||||
|
if n < last {
|
||||||
|
item += fmt.Sprintf(" /Next %d 0 R", n+1)
|
||||||
|
}
|
||||||
|
obj(item + " >>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xref := b.Len()
|
||||||
|
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
|
||||||
|
for _, off := range offsets {
|
||||||
|
fmt.Fprintf(&b, "%010d 00000 n \n", off)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R /Info 5 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lorem returns n lines of distinct, searchable filler: "p<page> l<line> …", so a test can tell
|
||||||
|
// which page a line came from.
|
||||||
|
func Lorem(page, n int) string {
|
||||||
|
words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"}
|
||||||
|
var lines []string
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
var w []string
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
w = append(w, words[(page*7+i*3+j)%len(words)])
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("p%d l%d %s", page, i, strings.Join(w, " ")))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func esc(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package pdftext_test
|
||||||
|
|
||||||
|
// P5a task 02: Extract (the child's work), ChildMain (the `sift pdftext` entry point) and Runner
|
||||||
|
// (the parent: exec, deadline, kill, parse).
|
||||||
|
//
|
||||||
|
// The Runner tests run the test binary itself as the child: TestMain turns into the child when
|
||||||
|
// SIFT_PDFTEXT_CHILD=1 is set, exactly as `sift pdftext` would. The two other variables are test
|
||||||
|
// hooks that live only here: SIFT_PDFTEXT_SLEEP delays the child (for the deadline test) and
|
||||||
|
// SIFT_PDFTEXT_PIDFILE records its pid (to prove it is gone afterwards).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdftest"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv("SIFT_PDFTEXT_CHILD") == "1" {
|
||||||
|
if p := os.Getenv("SIFT_PDFTEXT_PIDFILE"); p != "" {
|
||||||
|
_ = os.WriteFile(p, []byte(strconv.Itoa(os.Getpid())), 0o600)
|
||||||
|
}
|
||||||
|
if d, err := time.ParseDuration(os.Getenv("SIFT_PDFTEXT_SLEEP")); err == nil {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout))
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func three() []byte {
|
||||||
|
return pdftest.Build(pdftest.Doc{
|
||||||
|
Title: "Three Pages",
|
||||||
|
Pages: []string{
|
||||||
|
"Chapter one begins here.\nSecond line (with parens).",
|
||||||
|
"The quick brown fox is on page two.",
|
||||||
|
pdftest.Lorem(3, 5),
|
||||||
|
},
|
||||||
|
Outline: []pdftest.Outline{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type line struct {
|
||||||
|
Meta *pdftext.Meta `json:"meta"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Text *string `json:"text"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lines(t *testing.T, out []byte) []line {
|
||||||
|
t.Helper()
|
||||||
|
var ls []line
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 64<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
var l line
|
||||||
|
if err := json.Unmarshal(sc.Bytes(), &l); err != nil {
|
||||||
|
t.Fatalf("output line is not JSON: %q (%v)", sc.Text(), err)
|
||||||
|
}
|
||||||
|
ls = append(ls, l)
|
||||||
|
}
|
||||||
|
return ls
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractWritesMetaThenPages(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := pdftext.Extract(three(), 2000, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ls := lines(t, out.Bytes())
|
||||||
|
if len(ls) != 4 || ls[0].Meta == nil {
|
||||||
|
t.Fatalf("got %d lines, first meta=%v; want a meta line then 3 page lines:\n%s", len(ls), ls[0].Meta, out.String())
|
||||||
|
}
|
||||||
|
m := ls[0].Meta
|
||||||
|
if m.Pages != 3 || m.Title != "Three Pages" {
|
||||||
|
t.Errorf("meta = %+v, want 3 pages titled Three Pages", *m)
|
||||||
|
}
|
||||||
|
want := []pdftext.OutlineItem{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}}
|
||||||
|
if len(m.Outline) != 3 {
|
||||||
|
t.Fatalf("outline = %+v, want %+v", m.Outline, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if m.Outline[i] != want[i] {
|
||||||
|
t.Errorf("outline[%d] = %+v, want %+v", i, m.Outline[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, l := range ls[1:] {
|
||||||
|
if l.Page != i+1 || l.Text == nil || l.Error != "" {
|
||||||
|
t.Errorf("line %d = %+v, want page %d with text", i+1, l, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(*ls[1].Text, "Chapter one begins here.") || !strings.Contains(*ls[2].Text, "quick brown fox") || !strings.Contains(*ls[3].Text, "p3 l5") {
|
||||||
|
t.Errorf("page texts wrong: %q / %q / %q", *ls[1].Text, *ls[2].Text, *ls[3].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPageCap(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := pdftext.Extract(three(), 2, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ls := lines(t, out.Bytes())
|
||||||
|
if len(ls) != 3 || ls[0].Meta.Pages != 3 || ls[2].Page != 2 {
|
||||||
|
t.Errorf("maxPages 2: %d lines, meta %+v; want meta (pages 3) and pages 1-2", len(ls), ls[0].Meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractNoOutline(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := pdftext.Extract(pdftest.Build(pdftest.Doc{Pages: []string{"a", "b"}}), 10, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ls := lines(t, out.Bytes())
|
||||||
|
if ls[0].Meta.Outline == nil || len(ls[0].Meta.Outline) != 0 {
|
||||||
|
t.Errorf("outline = %#v, want an empty non-nil slice (JSON [])", ls[0].Meta.Outline)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), `"outline":[]`) {
|
||||||
|
t.Errorf("meta line must carry \"outline\":[]: %s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractErrors(t *testing.T) {
|
||||||
|
enc, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pdftext.Extract(enc, 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrEncrypted) {
|
||||||
|
t.Errorf("user-password pdf: %v, want ErrEncrypted", err)
|
||||||
|
}
|
||||||
|
owner, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-owner.pdf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := pdftext.Extract(owner, 10, &out); err != nil || !strings.Contains(out.String(), "quick brown fox") {
|
||||||
|
t.Errorf("owner-password-only pdf: err %v; want its text", err)
|
||||||
|
}
|
||||||
|
if err := pdftext.Extract([]byte("not a pdf at all"), 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrUnreadable) {
|
||||||
|
t.Errorf("garbage: %v, want ErrUnreadable", err)
|
||||||
|
}
|
||||||
|
// A truncated file either yields what it can or ErrUnreadable; it never panics.
|
||||||
|
b := three()
|
||||||
|
_ = pdftext.Extract(b[:len(b)*6/10], 10, &bytes.Buffer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChildMainExitCodes(t *testing.T) {
|
||||||
|
enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
in []byte
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{"ok", three(), 0},
|
||||||
|
{"encrypted", enc, 3},
|
||||||
|
{"unreadable", []byte("nope"), 4},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if code := pdftext.ChildMain([]string{"-max-pages", "5"}, bytes.NewReader(tc.in), &out); code != tc.code {
|
||||||
|
t.Errorf("%s: exit %d, want %d", tc.name, code, tc.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runner(env ...string) *pdftext.Runner {
|
||||||
|
return &pdftext.Runner{Exe: os.Args[0], Env: append([]string{"SIFT_PDFTEXT_CHILD=1"}, env...), Deadline: 20 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRun(t *testing.T) {
|
||||||
|
doc, err := runner().Run(context.Background(), three())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if doc.Pages != 3 || doc.Title != "Three Pages" || len(doc.Outline) != 3 || len(doc.Text) != 3 {
|
||||||
|
t.Fatalf("doc = %+v", doc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(doc.Text[1], "quick brown fox") {
|
||||||
|
t.Errorf("page 2 = %q", doc.Text[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerErrors(t *testing.T) {
|
||||||
|
enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
|
||||||
|
if _, err := runner().Run(context.Background(), enc); !errors.Is(err, pdftext.ErrEncrypted) {
|
||||||
|
t.Errorf("encrypted: %v, want ErrEncrypted", err)
|
||||||
|
}
|
||||||
|
if _, err := runner().Run(context.Background(), []byte("nope")); !errors.Is(err, pdftext.ErrUnreadable) {
|
||||||
|
t.Errorf("garbage: %v, want ErrUnreadable", err)
|
||||||
|
}
|
||||||
|
scanned := pdftest.Build(pdftest.Doc{Pages: []string{"", "", ""}, Scanned: []int{1, 2, 3}})
|
||||||
|
if _, err := runner().Run(context.Background(), scanned); !errors.Is(err, pdftext.ErrNoText) {
|
||||||
|
t.Errorf("scanned: %v, want ErrNoText", err)
|
||||||
|
}
|
||||||
|
r := runner()
|
||||||
|
r.MaxText = 100 // bytes of child output
|
||||||
|
if _, err := r.Run(context.Background(), three()); !errors.Is(err, pdftext.ErrTooMuchText) {
|
||||||
|
t.Errorf("output over MaxText: %v, want ErrTooMuchText", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The child is killed at the deadline and reaped: no process is left behind.
|
||||||
|
func TestRunnerDeadlineKillsTheChild(t *testing.T) {
|
||||||
|
pidfile := filepath.Join(t.TempDir(), "pid")
|
||||||
|
r := runner("SIFT_PDFTEXT_SLEEP=30s", "SIFT_PDFTEXT_PIDFILE="+pidfile)
|
||||||
|
r.Deadline = 300 * time.Millisecond
|
||||||
|
start := time.Now()
|
||||||
|
_, err := r.Run(context.Background(), three())
|
||||||
|
if !errors.Is(err, pdftext.ErrTimeout) {
|
||||||
|
t.Fatalf("err %v, want ErrTimeout", err)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 3*time.Second {
|
||||||
|
t.Errorf("Run returned after %v; the 300 ms deadline was not enforced", d)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(pidfile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("child never wrote its pid: %v", err)
|
||||||
|
}
|
||||||
|
pid, _ := strconv.Atoi(string(b))
|
||||||
|
if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
|
||||||
|
t.Errorf("child %d still exists after Run returned (kill -0: %v)", pid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's context cancels the child too.
|
||||||
|
func TestRunnerContextCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err := runner("SIFT_PDFTEXT_SLEEP=30s").Run(ctx, three())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("cancelled run succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner passes only Env to the child: nothing of the parent's environment leaks in.
|
||||||
|
func TestRunnerEnvIsExplicit(t *testing.T) {
|
||||||
|
t.Setenv("SIFT_PDFTEXT_SLEEP", "30s") // in the parent only
|
||||||
|
r := runner()
|
||||||
|
r.Deadline = 5 * time.Second
|
||||||
|
if _, err := r.Run(context.Background(), three()); err != nil {
|
||||||
|
t.Errorf("parent env leaked into the child (it slept): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# PDF fixtures
|
||||||
|
|
||||||
|
Only the two encrypted files are binary; every other PDF the tests use is built at test time by
|
||||||
|
`internal/pdftest`.
|
||||||
|
|
||||||
|
- `encrypted-user.pdf`: the `pdftest` three-page document, AES-256, user password `userpw`,
|
||||||
|
owner password `ownerpw`. Made with
|
||||||
|
`qpdf --encrypt userpw ownerpw 256 -- three.pdf encrypted-user.pdf`.
|
||||||
|
- `encrypted-owner.pdf`: the same document, AES-256 with an empty user password
|
||||||
|
(opens without one), owner password `ownerpw`:
|
||||||
|
`qpdf --encrypt "" ownerpw 256 -- three.pdf encrypted-owner.pdf`.
|
||||||
|
|
||||||
|
Both contain the phrases "Chapter one begins here." (page 1) and
|
||||||
|
"The quick brown fox is on page two." (page 2).
|
||||||
Binary file not shown.
@@ -0,0 +1,81 @@
|
|||||||
|
%PDF-1.7
|
||||||
|
%¿÷¢þ
|
||||||
|
1 0 obj
|
||||||
|
<< /Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 8 >> >> /Outlines 3 0 R /PageMode /UseOutlines /Pages 4 0 R /Type /Catalog >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Producer <43b5e97c0a7de15bc0f4dbcc052b5f80f21e0e3bede0eda33137092879ea433d> /Title <29f3ced7effab532ee871ab3ebc38a6869b6b7379f2fa12e139f3e2a1d1ee664> >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Count 3 /First 5 0 R /Last 6 0 R /Type /Outlines >>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<< /Count 3 /Kids [ 7 0 R 8 0 R 9 0 R ] /Type /Pages >>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<< /Dest [ 7 0 R /XYZ null null null ] /Next 10 0 R /Parent 3 0 R /Title <d2951c68344226cca87b8f66af448a05c4ba3c4baf7c7863293f2328ce407d09> >>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<< /Dest [ 9 0 R /XYZ null null null ] /Parent 3 0 R /Prev 10 0 R /Title <13b9a70c4f34e06e182eb823c2cb79a1f82c91f6c69ee89bf2093cd61d451407> >>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<< /Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 4 0 R /Resources << /Font << /F1 12 0 R >> /XObject << /Im1 13 0 R >> >> /Type /Page >>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<< /Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 4 0 R /Resources << /Font << /F1 12 0 R >> /XObject << /Im1 13 0 R >> >> /Type /Page >>
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
<< /Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 4 0 R /Resources << /Font << /F1 12 0 R >> /XObject << /Im1 13 0 R >> >> /Type /Page >>
|
||||||
|
endobj
|
||||||
|
10 0 obj
|
||||||
|
<< /Dest [ 8 0 R /XYZ null null null ] /Next 6 0 R /Parent 3 0 R /Prev 5 0 R /Title <b734ddb0e84e981224c172818d645644e95ae16c94cf45d02ef870d181f97422> >>
|
||||||
|
endobj
|
||||||
|
11 0 obj
|
||||||
|
<< /Length 128 /Filter /FlateDecode >>
|
||||||
|
stream
|
||||||
|
jl<ħ|Ýkj>Kþ"æ"-‚'agcYÓ“¡Áù ‹ä¾Îç„Â^›™h9Àu¢ò4DY>€ä‡ÝhSkvJxŠý␍÷À'‡ÿù/טò£Ð#¼Æo=ÖÛ:³1áÇ¢hÇôªÀè<Â|Hü“J‹xYz4�ÿÄendstream
|
||||||
|
endobj
|
||||||
|
12 0 obj
|
||||||
|
<< /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Subtype /Type1 /Type /Font >>
|
||||||
|
endobj
|
||||||
|
13 0 obj
|
||||||
|
<< /BitsPerComponent 8 /ColorSpace /DeviceGray /Height 2 /Subtype /Image /Type /XObject /Width 2 /Length 32 /Filter /FlateDecode >>
|
||||||
|
stream
|
||||||
|
Ë™"€_œ¼Ë57Ó”Ì60Ë%œ1´V\Ó$mG&0FåMendstream
|
||||||
|
endobj
|
||||||
|
14 0 obj
|
||||||
|
<< /Length 112 /Filter /FlateDecode >>
|
||||||
|
stream
|
||||||
|
,#c¾}jè¹Ájw�)æ�„›d7Éwp¨íi»gvÍñÎâH»6oA•ï´vD}–Ð9·G`1wÆŽ×XŒÝ‰žÄrÜ›Vf:I¡)x9ÅéÌß“>��WãxŽÄ‡¡ãŠ¤Ô«âÊ‘dendstream
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
<< /Length 160 /Filter /FlateDecode >>
|
||||||
|
stream
|
||||||
|
yx÷â°í%"(hß]�T~BùÞ]+$ík±Mû†e.ŒbÿPת®0ž;äòãæë%½Kû¾¤z‹C”¤²!̘£)(žœÛôŒáéÄ;/¬@yèºÚh¥Á qR|Ôcݹ²Ã((¼Õ{úc¿AÆeýÏΧDíÚ¼)‡(íÄ! ·#5u·ké#¹âW’<8pŽ÷I1XQ"¥Gendstream
|
||||||
|
endobj
|
||||||
|
16 0 obj
|
||||||
|
<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <2a0820bbf93fff95a410525ae20d4abca153af1e3464706a4f83e49c8caa92e62e160b6871c75b9e47a6cd6dd15fe6ab> /OE <210b63a9a2305637af02dc6e89409285f61f82adaafa125e950042528164e2a3> /P -4 /Perms <23688a77e80aeb8b536511ce497c729e> /R 6 /StmF /StdCF /StrF /StdCF /U <98531c24f9d0bbabd16cc589be7618a59de91065126679a37b26b5d684cbcbc76d34bf68dc61eafe1681b4d5a21826d1> /UE <75ac70233a6a28bcd012febedaf3cccf6c25af218f3318a9fa9bc0f94a0e1b53> /V 5 >>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 17
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000015 00000 n
|
||||||
|
0000000169 00000 n
|
||||||
|
0000000341 00000 n
|
||||||
|
0000000412 00000 n
|
||||||
|
0000000483 00000 n
|
||||||
|
0000000641 00000 n
|
||||||
|
0000000799 00000 n
|
||||||
|
0000000956 00000 n
|
||||||
|
0000001113 00000 n
|
||||||
|
0000001270 00000 n
|
||||||
|
0000001440 00000 n
|
||||||
|
0000001640 00000 n
|
||||||
|
0000001738 00000 n
|
||||||
|
0000001935 00000 n
|
||||||
|
0000002119 00000 n
|
||||||
|
0000002351 00000 n
|
||||||
|
trailer << /Info 2 0 R /Root 1 0 R /Size 17 /ID [<39c444018179222865caf844f4fb7254><39c444018179222865caf844f4fb7254>] /Encrypt 16 0 R >>
|
||||||
|
startxref
|
||||||
|
2899
|
||||||
|
%%EOF
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
module git.wntrmute.dev/kyle/sift
|
module git.wntrmute.dev/kyle/sift
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||||
|
github.com/go-pdfkit/extract v0.3.0
|
||||||
|
github.com/go-pdfkit/reader v0.6.0
|
||||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
|
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
|
||||||
github.com/itchyny/gojq v0.12.19
|
github.com/itchyny/gojq v0.12.19
|
||||||
github.com/pelletier/go-toml/v2 v2.4.3
|
github.com/pelletier/go-toml/v2 v2.4.3
|
||||||
@@ -21,6 +23,8 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-opentype/opentype v0.12.0 // indirect
|
||||||
|
github.com/go-pdfkit/pdffont v0.3.0 // indirect
|
||||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-opentype/fonts v0.8.0 h1:77i3VPIH90GbstzNb21mk+an4WvEOe2idC6W+J0n0fw=
|
||||||
|
github.com/go-opentype/fonts v0.8.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g=
|
||||||
|
github.com/go-opentype/opentype v0.12.0 h1:wBlcDi+3ZaNZXEt5z+Ixr11/cYYwi5W+jX6yTl/qr1I=
|
||||||
|
github.com/go-opentype/opentype v0.12.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU=
|
||||||
|
github.com/go-pdfkit/extract v0.3.0 h1:w1ansrvMNqd+Hy1p5fo1orh6hM8meb+xCNKNap/7omM=
|
||||||
|
github.com/go-pdfkit/extract v0.3.0/go.mod h1:EOWOcx3o0HDC3ryCMoE9pwNIb3mwVmnXBSpTRfO1VdY=
|
||||||
|
github.com/go-pdfkit/pdffont v0.3.0 h1:G5DKcAmsZJ0e17QhSrcUaL7PKEXKjUx4P/iGMl7bAbI=
|
||||||
|
github.com/go-pdfkit/pdffont v0.3.0/go.mod h1:bfmNLna1l1CljNX/Utg55YzFylovfmI7sJnvgA3bzKI=
|
||||||
|
github.com/go-pdfkit/reader v0.6.0 h1:KAabNOYUcTlZlNBTbG9bEhWP1NiZhjuhzUdavdTdfes=
|
||||||
|
github.com/go-pdfkit/reader v0.6.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=
|
||||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
||||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
||||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
||||||
|
|||||||
+2
-2
@@ -30,8 +30,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
maxReqBody = 64 << 10
|
maxReqBody = 64 << 10
|
||||||
writeTimeout = 120 * time.Second
|
writeTimeout = 240 * time.Second // P5a: a PDF may take 120 s to download + 20 s queued + 60 s to extract
|
||||||
requestDeadline = 90 * time.Second
|
requestDeadline = 210 * time.Second // P5a: the PDF path's worst case (see writeTimeout); other paths keep their own shorter timeouts
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ func TestJSONJQ(t *testing.T) {
|
|||||||
func TestUnsupportedType(t *testing.T) {
|
func TestUnsupportedType(t *testing.T) {
|
||||||
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/pdf")
|
// P5a: a PDF is supported now; a zip is still not.
|
||||||
_, _ = w.Write([]byte("%PDF"))
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
_, _ = w.Write([]byte("PK\x03\x04"))
|
||||||
}))
|
}))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
s, key := setupAPI(t, g)
|
s, key := setupAPI(t, g)
|
||||||
@@ -260,7 +261,7 @@ func TestJQTooLarge(t *testing.T) {
|
|||||||
func TestHTTPServerWriteTimeout(t *testing.T) {
|
func TestHTTPServerWriteTimeout(t *testing.T) {
|
||||||
s := New("test", &guard.Guard{}, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
s := New("test", &guard.Guard{}, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
hs := s.HTTPServer("127.0.0.1:0")
|
hs := s.HTTPServer("127.0.0.1:0")
|
||||||
if hs.WriteTimeout != 120*time.Second {
|
if hs.WriteTimeout != 240*time.Second { // P5a: 120 s download + 20 s queue + 60 s extraction fit inside
|
||||||
t.Fatalf("WriteTimeout %s", hs.WriteTimeout)
|
t.Fatalf("WriteTimeout %s", hs.WriteTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
readability "github.com/go-shiori/go-readability"
|
|
||||||
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2"
|
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2"
|
||||||
|
readability "github.com/go-shiori/go-readability"
|
||||||
"github.com/itchyny/gojq"
|
"github.com/itchyny/gojq"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/charset"
|
"golang.org/x/net/html/charset"
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
|
|
||||||
type nopConn struct{}
|
type nopConn struct{}
|
||||||
|
|
||||||
func (nopConn) Control(func(uintptr)) error { return nil }
|
func (nopConn) Control(func(uintptr)) error { return nil }
|
||||||
func (nopConn) Read(func(uintptr) bool) error { return nil }
|
func (nopConn) Read(func(uintptr) bool) error { return nil }
|
||||||
func (nopConn) Write(func(uintptr) bool) error { return nil }
|
func (nopConn) Write(func(uintptr) bool) error { return nil }
|
||||||
|
|
||||||
func mustAddr(s string) netip.Addr {
|
func mustAddr(s string) netip.Addr {
|
||||||
a, err := netip.ParseAddr(s)
|
a, err := netip.ParseAddr(s)
|
||||||
@@ -38,10 +38,10 @@ func TestCheckAddrTable(t *testing.T) {
|
|||||||
{"1.1.1.1", false, ""},
|
{"1.1.1.1", false, ""},
|
||||||
{"93.184.216.34", false, ""},
|
{"93.184.216.34", false, ""},
|
||||||
{"2001:4860:4860::8888", false, ""},
|
{"2001:4860:4860::8888", false, ""},
|
||||||
{"172.15.255.255", false, ""}, // just below 172.16/12
|
{"172.15.255.255", false, ""}, // just below 172.16/12
|
||||||
{"172.32.0.1", false, ""}, // just above 172.16/12
|
{"172.32.0.1", false, ""}, // just above 172.16/12
|
||||||
{"100.63.255.255", false, ""}, // just below CGNAT
|
{"100.63.255.255", false, ""}, // just below CGNAT
|
||||||
{"100.128.0.1", false, ""}, // just above CGNAT
|
{"100.128.0.1", false, ""}, // just above CGNAT
|
||||||
|
|
||||||
{"127.0.0.1", true, "loopback"},
|
{"127.0.0.1", true, "loopback"},
|
||||||
{"127.255.255.254", true, "loopback"},
|
{"127.255.255.254", true, "loopback"},
|
||||||
@@ -147,13 +147,13 @@ func TestDecimalOctalHexIPs(t *testing.T) {
|
|||||||
// All of these are 127.0.0.1 in inet_aton form.
|
// All of these are 127.0.0.1 in inet_aton form.
|
||||||
loopbacks := []string{
|
loopbacks := []string{
|
||||||
"http://127.0.0.1/",
|
"http://127.0.0.1/",
|
||||||
"http://2130706433/", // decimal
|
"http://2130706433/", // decimal
|
||||||
"http://0x7f000001/", // hex dword
|
"http://0x7f000001/", // hex dword
|
||||||
"http://017700000001/", // octal dword
|
"http://017700000001/", // octal dword
|
||||||
"http://0177.0.0.1/", // octal dotted
|
"http://0177.0.0.1/", // octal dotted
|
||||||
"http://0x7f.0.0.1/", // hex dotted
|
"http://0x7f.0.0.1/", // hex dotted
|
||||||
"http://127.1/", // short
|
"http://127.1/", // short
|
||||||
"http://127.0.1/", // 3-part
|
"http://127.0.1/", // 3-part
|
||||||
"http://0x7f.1/",
|
"http://0x7f.1/",
|
||||||
}
|
}
|
||||||
g := &Guard{}
|
g := &Guard{}
|
||||||
@@ -477,9 +477,9 @@ func mustURL(s string) *url.URL {
|
|||||||
|
|
||||||
// Ensure Control implements the Dialer hook shape used by net.Dialer.
|
// Ensure Control implements the Dialer hook shape used by net.Dialer.
|
||||||
var (
|
var (
|
||||||
_ func(network, address string, c syscall.RawConn) error = (*Guard)(nil).Control
|
_ func(network, address string, c syscall.RawConn) error = (*Guard)(nil).Control
|
||||||
_ func(ctx context.Context, network, address string, c syscall.RawConn) error = (*Guard)(nil).ControlContext
|
_ func(ctx context.Context, network, address string, c syscall.RawConn) error = (*Guard)(nil).ControlContext
|
||||||
_ func(req *http.Request, via []*http.Request) error = (*Guard)(nil).CheckRedirect
|
_ func(req *http.Request, via []*http.Request) error = (*Guard)(nil).CheckRedirect
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLocalhostName(t *testing.T) {
|
func TestLocalhostName(t *testing.T) {
|
||||||
@@ -544,20 +544,20 @@ func TestIANASpecialPurpose(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
// One address per required prefix from the P0 review.
|
// One address per required prefix from the P0 review.
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"2002:a00:1::1", // 6to4 of 10.0.0.1
|
"2002:a00:1::1", // 6to4 of 10.0.0.1
|
||||||
"2002:7f00:1::1", // 6to4 of 127.0.0.1
|
"2002:7f00:1::1", // 6to4 of 127.0.0.1
|
||||||
"2001::1", // Teredo 2001::/32 (inside 2001::/23)
|
"2001::1", // Teredo 2001::/32 (inside 2001::/23)
|
||||||
"64:ff9b:1::a00:1", // RFC 8215 local-use NAT64 embedding 10.0.0.1
|
"64:ff9b:1::a00:1", // RFC 8215 local-use NAT64 embedding 10.0.0.1
|
||||||
"fec0::1", // deprecated site-local
|
"fec0::1", // deprecated site-local
|
||||||
"198.18.0.1", // benchmarking
|
"198.18.0.1", // benchmarking
|
||||||
"198.19.255.255",
|
"198.19.255.255",
|
||||||
"192.0.0.1",
|
"192.0.0.1",
|
||||||
"240.0.0.1",
|
"240.0.0.1",
|
||||||
"255.255.255.255",
|
"255.255.255.255",
|
||||||
"100::1",
|
"100::1",
|
||||||
"2001:db8::1",
|
"2001:db8::1",
|
||||||
"2001:10::1", // ORCHID
|
"2001:10::1", // ORCHID
|
||||||
"2001:20::1", // ORCHIDv2
|
"2001:20::1", // ORCHIDv2
|
||||||
}
|
}
|
||||||
for _, s := range blocked {
|
for _, s := range blocked {
|
||||||
t.Run(s, func(t *testing.T) {
|
t.Run(s, func(t *testing.T) {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import "net/netip"
|
|||||||
// rather than decoded. IsGlobalUnicast remains a final gate in CheckAddr.
|
// rather than decoded. IsGlobalUnicast remains a final gate in CheckAddr.
|
||||||
//
|
//
|
||||||
// Sources:
|
// Sources:
|
||||||
// https://www.iana.org/assignments/iana-ipv4-special-registry
|
//
|
||||||
// https://www.iana.org/assignments/iana-ipv6-special-registry
|
// https://www.iana.org/assignments/iana-ipv4-special-registry
|
||||||
|
// https://www.iana.org/assignments/iana-ipv6-special-registry
|
||||||
var ianaDeny = []struct {
|
var ianaDeny = []struct {
|
||||||
prefix netip.Prefix
|
prefix netip.Prefix
|
||||||
reason string
|
reason string
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build tools
|
||||||
|
|
||||||
|
// Package sift pins the PDF engine chosen for P5a (docs/P5a.md §3) so go.mod and vendor/ carry it
|
||||||
|
// before the code that imports it exists. Remove this file once internal/pdftext imports both.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "github.com/go-pdfkit/extract"
|
||||||
|
_ "github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs the tasks of a plan one after another, each in its own fresh OpenCode session, and stops at
|
||||||
|
# the first task that does not end cleanly. This is the owner's tool; the implementing model never
|
||||||
|
# runs it. Adapted from crossbar's tools/run-plan.sh.
|
||||||
|
#
|
||||||
|
# Usage: tools/run-plan.sh docs/plans/<plan> [FIRST] FIRST is a task number such as 03
|
||||||
|
# Options (environment):
|
||||||
|
# SIFT_MODEL model to use (default llama.cpp/ornith-1.5-35b-a3b)
|
||||||
|
# OPENCODE_FLAGS extra flags for `opencode run` (default --pure)
|
||||||
|
# DRY_RUN=1 print what would be run and stop
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
plan="${1:?usage: tools/run-plan.sh docs/plans/<plan> [FIRST]}"
|
||||||
|
first="${2:-00}"
|
||||||
|
model="${SIFT_MODEL:-llama.cpp/ornith-1.5-35b-a3b}"
|
||||||
|
flags="${OPENCODE_FLAGS:---pure}"
|
||||||
|
log=docs/implementer-log.md
|
||||||
|
milestone=$(basename "$plan")
|
||||||
|
runs=".state/runs/$milestone"
|
||||||
|
# The model's own name for the attribution trailer: the part after the provider prefix.
|
||||||
|
mname="${model#*/}"
|
||||||
|
trailer="Co-Authored-By: $mname <$mname@llama.cpp.invalid>"
|
||||||
|
|
||||||
|
[ -d "$plan" ] || { echo "run-plan: $plan is not a directory" >&2; exit 2; }
|
||||||
|
[ -f "$log" ] || { echo "run-plan: run this from the repository root" >&2; exit 2; }
|
||||||
|
mkdir -p "$runs"
|
||||||
|
|
||||||
|
for task in "$plan"/[0-9][0-9]-*.md; do
|
||||||
|
name=$(basename "$task" .md)
|
||||||
|
number=$(echo "$name" | cut -c1-2)
|
||||||
|
[ "$number" -ge "$first" ] || continue
|
||||||
|
prompt="Read \`AGENTS.md\`, then read \`$task\` and do exactly that task. Your commit message must end with this exact trailer line: $trailer"
|
||||||
|
|
||||||
|
if [ "${DRY_RUN:-0}" = 1 ]; then
|
||||||
|
echo "opencode run -m $model $flags --title $milestone/$name \"$prompt\""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "run-plan: the working tree is not clean before $name; stopping" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
before=$(git rev-parse HEAD)
|
||||||
|
echo "=== $milestone/$name: started $(date '+%H:%M:%S')"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if ! opencode run -m "$model" $flags --title "$milestone/$name" "$prompt" \
|
||||||
|
> "$runs/$name.log" 2>&1; then
|
||||||
|
echo "run-plan: opencode exited with an error in $name; see $runs/$name.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "=== $milestone/$name: session ended $(date '+%H:%M:%S')"
|
||||||
|
|
||||||
|
if [ "$(git rev-parse HEAD)" = "$before" ]; then
|
||||||
|
echo "run-plan: $name made no commit; stopping" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "run-plan: $name left uncommitted changes; stopping" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git log -1 --format=%B | grep -qxF "$trailer"; then
|
||||||
|
echo "run-plan: $name's commit lacks the trailer '$trailer'; stopping" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -Eq "^\| *$milestone/$name *\|[^|]*\| *done *\|" "$log"; then
|
||||||
|
echo "run-plan: $name has no 'done' row in $log; stopping" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "=== $milestone/$name: ok ($(git rev-parse --short HEAD))"
|
||||||
|
done
|
||||||
|
echo "run-plan: all tasks done"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
coverage.out
|
||||||
|
*.out
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026 the go-opentype/opentype authors
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# go-opentype/opentype
|
||||||
|
|
||||||
|
[](https://github.com/go-opentype/opentype/actions/workflows/ci.yml)
|
||||||
|
[](https://pkg.go.dev/github.com/go-opentype/opentype)
|
||||||
|

|
||||||
|

|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
|
Pure-Go, **CGO=0, standard-library-only** parser, shaper and anti-aliased
|
||||||
|
rasteriser for TrueType/OpenType fonts. No `golang.org/x/*`, no third-party
|
||||||
|
modules — it imports only `image`, `encoding/binary`, `math`, `errors` and
|
||||||
|
friends, so it builds for every Go target including `GOOS=js GOARCH=wasm`.
|
||||||
|
|
||||||
|
It exists to functionally replace the narrow slice of
|
||||||
|
`golang.org/x/image/font/opentype` that a glyph-blitting UI needs — parse a
|
||||||
|
font blob, build a face at a pixel size, pull per-rune advances and 8-bit
|
||||||
|
alpha coverage masks — and then goes further: CFF/CFF2 outlines, variable
|
||||||
|
fonts, GPOS/GSUB shaping, kerning and TrueType/CFF hinting are all in scope.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get github.com/go-opentype/opentype
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-opentype/opentype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ttf, err := os.ReadFile("MyFont.ttf") // or .otf (CFF/CFF2)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := opentype.Parse(ttf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
face := f.NewFace(16) // 16px per em
|
||||||
|
|
||||||
|
m := face.Metrics() // Metrics{Ascent, Descent, Height} in pixels
|
||||||
|
w := face.Measure("Hello") // total advance width in pixels
|
||||||
|
adv := face.Advance('H') // one rune's advance in pixels
|
||||||
|
fmt.Println(m, w, adv)
|
||||||
|
|
||||||
|
// Rasterise a glyph with (penX, baselineY) as the pen origin on the baseline.
|
||||||
|
bounds, mask, maskp, advance, ok := face.GlyphMask('H', 0, 16)
|
||||||
|
if ok && mask != nil {
|
||||||
|
// Composite the coverage mask onto your destination:
|
||||||
|
// pixel (maskp.X+i, maskp.Y+j) covers destination (bounds.Min.X+i, bounds.Min.Y+j).
|
||||||
|
_ = bounds
|
||||||
|
_ = maskp
|
||||||
|
_ = advance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `GlyphMask` shape deliberately mirrors `x/image`'s `font.Face.Glyph`, so
|
||||||
|
swapping this in for the x/image face is mechanical.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **sfnt container** parsing (`0x00010000` TrueType and `OTTO` CFF magics)
|
||||||
|
- **TrueType `glyf` outlines** — simple and composite glyphs, implied
|
||||||
|
on-curve midpoint synthesis, cycle/depth-guarded composites
|
||||||
|
- **CFF and CFF2 outlines** — Type 2 charstrings, subroutines, `seac`
|
||||||
|
accent composition, CFF2 variable-font blend operators
|
||||||
|
- **Variable fonts** — `fvar` axes and named instances, `avar` axis-value
|
||||||
|
mapping, `gvar`/CFF2 glyph-outline interpolation, and `HVAR`/`VVAR`/`MVAR`
|
||||||
|
metric variation, via `(*Face).SetVariation`
|
||||||
|
- **GSUB/GPOS shaping** — ligatures, contextual and positional-form
|
||||||
|
substitution (`isol`/`init`/`medi`/`fina`), pair/single/cursive
|
||||||
|
positioning, mark-to-base and mark-to-mark attachment
|
||||||
|
- **Kerning** — GPOS pair positioning with legacy `kern`-table fallback
|
||||||
|
through a single `Kerner`
|
||||||
|
- **Hinting** — TrueType instruction interpreter and a CFF/Type 2 stem
|
||||||
|
grid-fitter, toggled per `Face` with `SetHinting`; optional
|
||||||
|
`SetStemDarkening`
|
||||||
|
- **Vertical metrics** — `vhea`/`vmtx`/`VORG`-aware vertical advances and
|
||||||
|
origins for vertical writing modes
|
||||||
|
- **OpenType `MATH` table** — math-typesetting metrics (constants, per-glyph
|
||||||
|
italic correction / top-accent attachment / four-corner kerning, and
|
||||||
|
stretchy-glyph size variants and assemblies), exposed pixel-scaled through
|
||||||
|
a `Face`
|
||||||
|
- **Font descriptors** — the OS/2- and `head`/`post`-derived scalars a PDF
|
||||||
|
`/FontDescriptor` or CSS `@font-face` needs (`Ascent`, `Descent`,
|
||||||
|
`FontBBox`, `CapHeight`, `XHeight`, `ItalicAngle`, `WeightClass`,
|
||||||
|
`WidthClass`, `StemV`, `Flags`, `IsFixedPitch`/`IsItalic`/`IsSerif`)
|
||||||
|
- **Subsetting & instancing** — `SubsetTrueType`/`SubsetCFF` cut a font down
|
||||||
|
to a glyph set (closing composite/`seac` dependencies and remapping ids),
|
||||||
|
and `Instance`/`InstanceBytes` bake a variable font to a static instance —
|
||||||
|
the writer side needed to embed fonts in PDFs
|
||||||
|
- **Anti-aliased rasterisation** via 4×4 supersampling under the non-zero
|
||||||
|
winding rule
|
||||||
|
|
||||||
|
A `Font` is immutable after `Parse` and safe for concurrent use. A `Face`
|
||||||
|
caches rasterised glyphs and is **not** safe for concurrent use; build one
|
||||||
|
`Face` per goroutine if needed.
|
||||||
|
|
||||||
|
## API tour
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `Parse(b []byte) (*Font, error)` | Decode an sfnt (TrueType or CFF/OTTO) font blob. |
|
||||||
|
| `(*Font).NumGlyphs() int` | Glyph count (from `maxp`). |
|
||||||
|
| `(*Font).GlyphIndex(r rune) (GlyphIndex, bool)` | Map a rune via the cmap. |
|
||||||
|
| `(*Font).GlyphIndexVariation(r, vs rune) (GlyphIndex, bool)` | Map a rune + Unicode variation selector. |
|
||||||
|
| `(*Font).Axes() []Axis` | Variable-font design axes (`fvar`). |
|
||||||
|
| `(*Font).NamedInstances() []NamedInstance` | Named positions in the variation space. |
|
||||||
|
| `(*Font).GPOS() *GPOS` / `(*Font).GSUB() *GSUB` | Parsed layout tables, or `nil`. |
|
||||||
|
| `(*Font).HasVerticalMetrics() bool` | Whether `vhea`/`vmtx` are present. |
|
||||||
|
| `(*Font).NewFace(sizePx int) *Face` | Build a sized face (scale = `sizePx / unitsPerEm`). |
|
||||||
|
| `(*Face).Metrics() Metrics` | `Ascent`, `Descent`, `Height` in pixels. |
|
||||||
|
| `(*Face).Advance(r rune) int` / `(*Face).Measure(s string) int` | Rune / string advance in pixels. |
|
||||||
|
| `(*Face).Kern(prev, r rune) int` / `(*Face).MeasureKerned(s string) int` | GPOS-or-`kern` pair adjustment. |
|
||||||
|
| `(*Face).GlyphMask(r, x, y) (image.Rectangle, *image.Alpha, image.Point, int, bool)` | Rasterised glyph + advance. |
|
||||||
|
| `(*Face).Shape(text string, features ...string) []GlyphIndex` | GSUB-substituted glyph run. |
|
||||||
|
| `(*Face).ShapePositioned(text string, features ...string) []PositionedGlyph` | GSUB + GPOS glyph run with pen offsets. |
|
||||||
|
| `(*Face).SetVariation(coords map[string]float64)` | Instance a variable font at the given axis coordinates. |
|
||||||
|
| `(*Face).SetHinting(on bool)` / `(*Face).SetStemDarkening(on bool)` | Toggle the TrueType/CFF hinter. |
|
||||||
|
| `(*Face).VerticalAdvance(r rune) int` / `(*Face).VerticalOrigin(r rune) (int, bool)` | Vertical writing-mode metrics. |
|
||||||
|
| `(*Font).HasMath() bool` | Whether the font carries an OpenType `MATH` table. |
|
||||||
|
| `(*Face).MathConstant(w MathConstant) int` / `(*Face).ItalicCorrection(g GlyphIndex) int` / `(*Face).MathVariants(g, vertical) ([]MathVariant, *MathAssembly)` | Pixel-scaled `MATH`-table metrics for a math-layout engine. |
|
||||||
|
| `(*Font).Ascent/Descent/LineGap/FontBBox/CapHeight/XHeight/ItalicAngle/WeightClass/WidthClass/StemV/Flags/IsFixedPitch/IsItalic/IsSerif` | Font-descriptor scalars for PDF `/FontDescriptor` and CSS `@font-face`. |
|
||||||
|
| `(*Font).Instance(coords map[string]float64) (*Font, error)` / `(*Font).InstanceBytes(coords) ([]byte, error)` | Bake a variable font to a static instance (as a `*Font` or a new sfnt blob). |
|
||||||
|
| `(*Font).SubsetTrueType(gids []GlyphIndex) ([]byte, map[GlyphIndex]GlyphIndex, error)` | Cut a TrueType font to a glyph set, closing composites and returning the old→new id map. |
|
||||||
|
| `(*Font).SubsetCFF(gids []GlyphIndex) ([]byte, error)` | Cut a CFF/OTTO font to a glyph set (closing `seac` accents). |
|
||||||
|
|
||||||
|
See [`example_test.go`](./example_test.go) for runnable examples of each of
|
||||||
|
these, and `go doc github.com/go-opentype/opentype` for the full reference.
|
||||||
|
|
||||||
|
## Support matrix
|
||||||
|
|
||||||
|
- sfnt container (`0x00010000` and `true` TrueType magics, `OTTO` CFF magic)
|
||||||
|
- `head`, `maxp`, `hhea`, `hmtx` (including the trailing-run shared advance)
|
||||||
|
- `cmap` formats **0, 2, 4, 6, 8, 10, 12, 13** and **14** (Unicode variation
|
||||||
|
sequences), preferring the richest available (12/13 over 4/6)
|
||||||
|
- `loca` (short and long), `glyf` simple and composite TrueType glyphs
|
||||||
|
- CFF and CFF2 (`OTTO`) Type 2 charstring outlines, including `seac`
|
||||||
|
- `fvar`/`avar`/`gvar`/`HVAR`/`VVAR`/`MVAR` and CFF2 blends for variable
|
||||||
|
fonts, plus `Instance`/`InstanceBytes` to bake a static instance
|
||||||
|
- `GSUB`/`GPOS`/`GDEF`, the legacy `kern` table
|
||||||
|
- OS/2 + `head`/`post` **font-descriptor** scalars (`Ascent`, `Descent`,
|
||||||
|
`FontBBox`, `CapHeight`, `XHeight`, `ItalicAngle`, `WeightClass`,
|
||||||
|
`WidthClass`, `StemV`, `Flags`, …) for PDF/CSS embedding
|
||||||
|
- **subsetting** — `SubsetTrueType` (glyf, composite-closure aware, with an
|
||||||
|
old→new id map) and `SubsetCFF` (`seac`-closure aware)
|
||||||
|
- the OpenType **`MATH`** table — math-typesetting metrics (constants, per-glyph
|
||||||
|
italic correction / top-accent attachment / corner kerning, and stretchy-glyph
|
||||||
|
size variants and assemblies), exposed pixel-scaled through a `Face`
|
||||||
|
(`HasMath`, `MathConstant`, `ItalicCorrection`, `TopAccentAttachment`,
|
||||||
|
`MathKern`, `MathVariants`); math *layout* is left to a higher-level engine
|
||||||
|
- TrueType instruction hinting and a CFF stem grid-fitter/darkener
|
||||||
|
- `vhea`/`vmtx`/`VORG` vertical metrics
|
||||||
|
- anti-aliased rasterisation via 4×4 supersampling under the non-zero
|
||||||
|
winding rule
|
||||||
|
|
||||||
|
Rasterisation uses uniform supersampling (not the delta-hinted rasterisation
|
||||||
|
of a native TrueType/CFF hinter's drop-out control), so it favours
|
||||||
|
correctness and portability over the very last drop of small-size sharpness.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests never depend on an external font to reach 100% coverage: they
|
||||||
|
synthesise minimal-but-valid TrueType and CFF fonts in memory to
|
||||||
|
deterministically exercise every parse, shape and raster branch, including
|
||||||
|
the error paths. A real-world font, Adobe's Source Serif 4 (SIL Open Font
|
||||||
|
License, bundled under [`testdata/`](./testdata)), is used in
|
||||||
|
[`example_test.go`](./example_test.go) and in a handful of sanity-check
|
||||||
|
tests, so the documented examples double as an end-to-end smoke test against
|
||||||
|
production CFF charstrings and Private DICTs. CI enforces **100.0% statement
|
||||||
|
coverage**, `go vet`, and a cross-compile smoke over
|
||||||
|
`linux/{amd64,arm64,riscv64,loong64,ppc64le,s390x}`, `js/wasm`,
|
||||||
|
`darwin/arm64` and `windows/amd64`.
|
||||||
|
|
||||||
|
## Part of the go-opentype pure-Go text stack
|
||||||
|
|
||||||
|
`go-opentype/opentype` is the parsing/shaping/rasterising engine at the base
|
||||||
|
of a dependency-free text stack:
|
||||||
|
|
||||||
|
- **[opentype](https://github.com/go-opentype/opentype)** (this repo) — font
|
||||||
|
parsing, GSUB/GPOS shaping, hinting and rasterisation.
|
||||||
|
- **[bidi](https://github.com/go-opentype/bidi)** — a full Unicode
|
||||||
|
Bidirectional Algorithm (UBA) implementation, for ordering mixed
|
||||||
|
left-to-right/right-to-left text before it is shaped.
|
||||||
|
- **[shape](https://github.com/go-opentype/shape)** — a HarfBuzz-lite
|
||||||
|
complex-script shaper (Arabic, Indic, Hangul, USE, Egyptian
|
||||||
|
hieroglyphs, ...) built on this package's GSUB/GPOS engine.
|
||||||
|
- **[fonts](https://github.com/go-opentype/fonts)** — 46 bundled OFL/BSD
|
||||||
|
font families (Latin, non-Latin scripts and CJK), per-family lazily
|
||||||
|
`go:embed`-ed, ready to feed to `Parse`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BSD-3-Clause. See [LICENSE](./LICENSE).
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// This file adds by-glyph-index advance accessors that honour the face's current
|
||||||
|
// variation instance. Face.Advance and Face.VerticalAdvance take a rune and go
|
||||||
|
// through the cmap; a text layer that has already resolved glyph indices (a
|
||||||
|
// shaper, or a PDF writer emitting a /W width array) needs the advance of a glyph
|
||||||
|
// it names directly, at the same instance the outlines are rendered for. Without
|
||||||
|
// these it would have to rasterise the glyph or reach past the cmap by hand.
|
||||||
|
|
||||||
|
// AdvanceIndex returns the horizontal advance of glyph gid in whole pixels at the
|
||||||
|
// face's size, honouring the face's current variation (the HVAR delta, or the
|
||||||
|
// gvar phantom-point fallback, is folded in exactly as Face.Advance does for a
|
||||||
|
// rune). With no variation set it is the base advance scaled to pixels. It is the
|
||||||
|
// by-glyph-index counterpart of Face.Advance, for a caller that already holds a
|
||||||
|
// glyph id rather than a rune. An out-of-range gid advances by zero.
|
||||||
|
func (fc *Face) AdvanceIndex(gid GlyphIndex) int {
|
||||||
|
return roundInt(fc.hAdvanceUnits(gid) * fc.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceIndexUnits returns the horizontal advance of glyph gid in font units at
|
||||||
|
// the face's current variation, as a float64 (the value AdvanceIndex rounds and
|
||||||
|
// scales to pixels). It is the exact width a PDF /W array wants — font-unit widths
|
||||||
|
// that track the instanced font — without the caller re-deriving the HVAR delta.
|
||||||
|
// An out-of-range gid advances by zero.
|
||||||
|
func (fc *Face) AdvanceIndexUnits(gid GlyphIndex) float64 {
|
||||||
|
return fc.hAdvanceUnits(gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerticalAdvanceIndex returns the vertical advance (advance height) of glyph gid
|
||||||
|
// in whole pixels at the face's size, honouring the face's current variation (the
|
||||||
|
// VVAR delta is folded in). When the font has no vmtx table the advance is one em.
|
||||||
|
// It is the by-glyph-index counterpart of Face.VerticalAdvance.
|
||||||
|
func (fc *Face) VerticalAdvanceIndex(gid GlyphIndex) int {
|
||||||
|
adv := float64(fc.font.verticalAdvance(gid))
|
||||||
|
if fc.varCoords != nil {
|
||||||
|
adv += fc.font.verticalAdvanceDelta(int(gid), fc.varNorm)
|
||||||
|
}
|
||||||
|
return roundInt(adv * fc.scale)
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file implements the optional 'avar' (Axis Variations) table. It refines
|
||||||
|
// the linear normalization performed by NormalizeCoords: for each axis it maps
|
||||||
|
// the normalized coordinate through a piecewise-linear segment map, letting a
|
||||||
|
// font make, say, the visual midpoint of a weight axis land somewhere other
|
||||||
|
// than the arithmetic midpoint. A font without 'avar' normalizes linearly.
|
||||||
|
|
||||||
|
// avarMap is one (from, to) control point of an axis segment map, both in the
|
||||||
|
// normalized F2Dot14 domain decoded to float64.
|
||||||
|
type avarMap struct {
|
||||||
|
from, to float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// avarTable holds one segment map per axis, in axis order.
|
||||||
|
type avarTable struct {
|
||||||
|
segments [][]avarMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAvar decodes the 'avar' table: a header followed by one SegmentMaps
|
||||||
|
// record per axis, each a count and that many (fromCoordinate, toCoordinate)
|
||||||
|
// F2Dot14 pairs.
|
||||||
|
func (f *Font) parseAvar(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.u16() // majorVersion
|
||||||
|
r.u16() // minorVersion
|
||||||
|
r.u16() // reserved
|
||||||
|
axisCount := int(r.u16())
|
||||||
|
segments := make([][]avarMap, axisCount)
|
||||||
|
for i := range segments {
|
||||||
|
count := int(r.u16())
|
||||||
|
maps := make([]avarMap, count)
|
||||||
|
for j := range maps {
|
||||||
|
maps[j].from = r.f2dot14()
|
||||||
|
maps[j].to = r.f2dot14()
|
||||||
|
}
|
||||||
|
segments[i] = maps
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: avar table: %w", r.err)
|
||||||
|
}
|
||||||
|
f.avar = &avarTable{segments: segments}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply maps a normalized coordinate v for the given axis through its segment
|
||||||
|
// map. Control points are assumed sorted by ascending fromCoordinate (as the
|
||||||
|
// specification requires). An axis with fewer than two control points, or an
|
||||||
|
// index past the table, is treated as identity.
|
||||||
|
func (a *avarTable) apply(axis int, v float64) float64 {
|
||||||
|
if axis >= len(a.segments) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
seg := a.segments[axis]
|
||||||
|
if len(seg) < 2 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v <= seg[0].from {
|
||||||
|
return seg[0].to
|
||||||
|
}
|
||||||
|
for i := 1; i < len(seg); i++ {
|
||||||
|
if v < seg[i].from {
|
||||||
|
lo, hi := seg[i-1], seg[i]
|
||||||
|
return lo.to + (hi.to-lo.to)*(v-lo.from)/(hi.from-lo.from)
|
||||||
|
}
|
||||||
|
if v == seg[i].from {
|
||||||
|
return seg[i].to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seg[len(seg)-1].to
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
// Copyright (c) 2026, the go-opentype authors
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCFF decodes a bare CFF font program — a Compact Font Format font that
|
||||||
|
// is not wrapped in an sfnt container, which is how a PDF carries one as a
|
||||||
|
// FontFile3 of subtype Type1C. Such a program has no head, no hmtx and no
|
||||||
|
// character map: the size of its units comes from its font matrix, its
|
||||||
|
// advances from the charstrings themselves, and the way from a name or a byte
|
||||||
|
// to a glyph from its own charset and encoding. [Font.HasCharacterMap] is
|
||||||
|
// false for the result, and [Font.GlyphIndexByName] and
|
||||||
|
// [Font.GlyphIndexByCode] are how it is addressed.
|
||||||
|
//
|
||||||
|
// The byte slice is retained, not copied, and must not be mutated afterwards.
|
||||||
|
func ParseCFF(b []byte) (*Font, error) {
|
||||||
|
cff, err := parseCFF(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := len(cff.charStrings)
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: the font has no glyphs")
|
||||||
|
}
|
||||||
|
f := &Font{
|
||||||
|
cff: cff,
|
||||||
|
numGlyphs: n,
|
||||||
|
unitsPerEm: unitsPerEmOf(cff.fontMatrix),
|
||||||
|
numberOfHMetrics: n,
|
||||||
|
}
|
||||||
|
f.advances, f.lsbs = cff.metrics(n)
|
||||||
|
// A bare program says nothing about how the line is spaced; a page of one
|
||||||
|
// is laid out by whatever carries it, which for a PDF is the text matrix.
|
||||||
|
f.ascender, f.descender = f.unitsPerEm, 0
|
||||||
|
f.buildNames()
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitsPerEmOf turns a font matrix into the number of units to the em, which
|
||||||
|
// is what the rest of this package counts in. A matrix of a thousandth is the
|
||||||
|
// usual one and gives a thousand.
|
||||||
|
func unitsPerEmOf(m [6]float64) int {
|
||||||
|
upem := int(math.Round(1 / m[0]))
|
||||||
|
if upem < 16 || upem > 16384 {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
return upem
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics is the advance and left side bearing of every glyph, read from the
|
||||||
|
// charstrings. A charstring that does not say gets the font's default.
|
||||||
|
func (c *cffTable) metrics(n int) (advances, lsbs []int) {
|
||||||
|
advances = make([]int, n)
|
||||||
|
lsbs = make([]int, n)
|
||||||
|
def, nominal := 0.0, 0.0
|
||||||
|
if c.priv != nil {
|
||||||
|
def, nominal = c.priv.defaultWidthX, c.priv.nominalWidthX
|
||||||
|
}
|
||||||
|
for gid := 0; gid < n; gid++ {
|
||||||
|
w := def
|
||||||
|
if delta, ok := charstringWidth(c.charStrings[gid]); ok {
|
||||||
|
w = nominal + delta
|
||||||
|
}
|
||||||
|
advances[gid] = int(math.Round(w))
|
||||||
|
}
|
||||||
|
return advances, lsbs
|
||||||
|
}
|
||||||
|
|
||||||
|
// charstringWidth reads the optional width a Type 2 charstring may carry in
|
||||||
|
// front of its first stem, move or endchar operator. It reads operands only,
|
||||||
|
// and gives up at the first operator that could hide one behind a subroutine
|
||||||
|
// rather than guess.
|
||||||
|
func charstringWidth(cs []byte) (float64, bool) {
|
||||||
|
var nOperands int
|
||||||
|
var first float64
|
||||||
|
for i := 0; i < len(cs); {
|
||||||
|
b := cs[i]
|
||||||
|
switch {
|
||||||
|
case b >= 32 || b == 28:
|
||||||
|
v, next, ok := t2Operand(cs, i)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if nOperands == 0 {
|
||||||
|
first = v
|
||||||
|
}
|
||||||
|
nOperands++
|
||||||
|
i = next
|
||||||
|
case b == 1 || b == 3 || b == 18 || b == 23: // hstem, vstem, hstemhm, vstemhm
|
||||||
|
return first, nOperands%2 == 1
|
||||||
|
case b == 19 || b == 20: // hintmask, cntrmask
|
||||||
|
return first, nOperands%2 == 1
|
||||||
|
case b == 21: // rmoveto
|
||||||
|
return first, nOperands > 2
|
||||||
|
case b == 22 || b == 4: // hmoveto, vmoveto
|
||||||
|
return first, nOperands > 1
|
||||||
|
case b == 14: // endchar
|
||||||
|
return first, nOperands == 1 || nOperands == 5
|
||||||
|
default:
|
||||||
|
// A subroutine call, or an arithmetic operator: the width, if
|
||||||
|
// there is one, is no longer in front of us.
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// t2Operand reads one Type 2 charstring operand, giving its value and where
|
||||||
|
// the next byte is.
|
||||||
|
func t2Operand(cs []byte, i int) (float64, int, bool) {
|
||||||
|
b := cs[i]
|
||||||
|
switch {
|
||||||
|
case b == 28:
|
||||||
|
if i+2 >= len(cs) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return float64(int16(uint16(cs[i+1])<<8 | uint16(cs[i+2]))), i + 3, true
|
||||||
|
case b < 247:
|
||||||
|
return float64(int(b) - 139), i + 1, true
|
||||||
|
case b < 251:
|
||||||
|
if i+1 >= len(cs) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return float64((int(b)-247)*256 + int(cs[i+1]) + 108), i + 2, true
|
||||||
|
case b < 255:
|
||||||
|
if i+1 >= len(cs) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return float64(-(int(b)-251)*256 - int(cs[i+1]) - 108), i + 2, true
|
||||||
|
default: // 255: a sixteen-sixteen fixed-point number
|
||||||
|
if i+4 >= len(cs) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
v := int32(uint32(cs[i+1])<<24 | uint32(cs[i+2])<<16 | uint32(cs[i+3])<<8 | uint32(cs[i+4]))
|
||||||
|
return float64(v) / 65536, i + 5, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNames indexes the font by the names it gives its glyphs, so that a
|
||||||
|
// document addressing it by name has somewhere to look. A font addressed by
|
||||||
|
// identifier names nothing, and this leaves it alone.
|
||||||
|
func (f *Font) buildNames() {
|
||||||
|
if f.cff == nil || f.cff.isCID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.glyphNames = make(map[string]GlyphIndex, f.numGlyphs)
|
||||||
|
for gid := 0; gid < f.numGlyphs; gid++ {
|
||||||
|
name, ok := f.cff.glyphName(gid)
|
||||||
|
if !ok || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := f.glyphNames[name]; !seen {
|
||||||
|
f.glyphNames[name] = GlyphIndex(gid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphName is what the font calls a glyph. ok is false for a font that does
|
||||||
|
// not name its glyphs — a TrueType font usually does not, and a CFF font
|
||||||
|
// addressed by character identifier never does.
|
||||||
|
func (f *Font) GlyphName(gid GlyphIndex) (string, bool) {
|
||||||
|
if f.t1 != nil {
|
||||||
|
return f.t1.glyphName(int(gid))
|
||||||
|
}
|
||||||
|
if f.cff == nil || f.cff.isCID {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return f.cff.glyphName(int(gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphIndexByName maps a glyph name to its index. ok is false when the font
|
||||||
|
// names no glyphs or has none of that name.
|
||||||
|
func (f *Font) GlyphIndexByName(name string) (GlyphIndex, bool) {
|
||||||
|
gid, ok := f.glyphNames[name]
|
||||||
|
return gid, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphIndexByCode maps a byte to a glyph through the font program's own
|
||||||
|
// built-in encoding — the Standard Encoding unless the program said otherwise.
|
||||||
|
// ok is false for a font that carries no such encoding, which is every
|
||||||
|
// TrueType font and every CFF font addressed by character identifier.
|
||||||
|
func (f *Font) GlyphIndexByCode(code byte) (GlyphIndex, bool) {
|
||||||
|
if f.t1 != nil {
|
||||||
|
return f.t1.glyphByCode(code)
|
||||||
|
}
|
||||||
|
if f.cff == nil || f.cff.isCID {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
gid, ok := f.cff.glyphByCode(code)
|
||||||
|
if !ok || gid >= f.numGlyphs {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(gid), true
|
||||||
|
}
|
||||||
+1202
File diff suppressed because it is too large
Load Diff
+724
@@ -0,0 +1,724 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file adds CFF2 (variable Compact Font Format) outline support, so
|
||||||
|
// variable OpenType/CFF fonts (variable .otf, e.g. Source Serif Variable and
|
||||||
|
// many Adobe families) can be parsed and instanced. CFF2 is a close variant of
|
||||||
|
// CFF1/Type2 (cff.go): it shares the INDEX/DICT encodings and nearly all of the
|
||||||
|
// Type2 charstring path operators, so this file reuses cff.go's INDEX/DICT
|
||||||
|
// parsers, the Type2 operand decoder, and the whole Bézier-flattening pen
|
||||||
|
// (t2machine's moveTo/lineTo/curveTo and the curve helpers) to emit the same
|
||||||
|
// []contour model glyf.go produces.
|
||||||
|
//
|
||||||
|
// CFF2 differs from CFF1 in these ways, all handled here:
|
||||||
|
// - Header carries a 16-bit topDictLength; the Top DICT is a single DICT (no
|
||||||
|
// Top DICT INDEX), and every INDEX uses a 32-bit count (parseIndex2).
|
||||||
|
// - There is no per-glyph advance width, no endchar, no seac and no charset;
|
||||||
|
// charstrings simply end at their last byte.
|
||||||
|
// - Outlines vary with the design space: the 'blend' and 'vsindex' charstring
|
||||||
|
// operators pull deltas from the shared OpenType ItemVariationStore
|
||||||
|
// ('vstore') and interpolate operands in place at a normalized coordinate.
|
||||||
|
// - The Private DICT and its Local Subrs live per Font DICT in the FDArray,
|
||||||
|
// selected per glyph by FDSelect.
|
||||||
|
//
|
||||||
|
// CFF2 hinting is supported: hstem/vstem/hintmask stems and each Font DICT's
|
||||||
|
// Private-DICT hint parameters are recorded here and grid-fitted by cffhint.go
|
||||||
|
// exactly as for CFF1, so Face.SetHinting(true) grid-fits CFF2 glyphs too.
|
||||||
|
// Deferred (not implemented here, matching cff.go's scope): FDSelect format 4
|
||||||
|
// (for >65535 glyphs) and non-format-1 ItemVariationStores are rejected; metric
|
||||||
|
// variation (HVAR and friends) is out of scope. The interpreter's operand stack
|
||||||
|
// limit follows the CFF2 maximum.
|
||||||
|
|
||||||
|
// maxCFF2Stack is the CFF2 charstring operand-stack limit (the spec permits 513
|
||||||
|
// entries; blend expands the stack well past the Type2 limit of 48).
|
||||||
|
const maxCFF2Stack = 513
|
||||||
|
|
||||||
|
// cff2Table is a parsed CFF2 table: the CharStrings INDEX (one charstring per
|
||||||
|
// glyph), the Global Subr INDEX with its bias, the per-Font-DICT Local Subr
|
||||||
|
// INDEXes (with biases and default variation-store indices), the per-glyph
|
||||||
|
// Font-DICT selector, and the shared ItemVariationStore driving blends.
|
||||||
|
type cff2Table struct {
|
||||||
|
charStrings [][]byte
|
||||||
|
globalSubrs [][]byte
|
||||||
|
gsubrBias int
|
||||||
|
fdSelect []int // gid -> Font DICT index; nil means every glyph uses 0
|
||||||
|
localSubrs [][][]byte // per Font DICT: its Local Subr INDEX
|
||||||
|
localBias []int // per Font DICT: its Local Subr bias
|
||||||
|
privVsindex []int // per Font DICT: default vsindex from the Private DICT
|
||||||
|
privHints []*cffPrivate // per Font DICT: parsed Private-DICT hint parameters
|
||||||
|
vstore *itemVarStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionAxis is one axis of one variation region: the tent function's lower,
|
||||||
|
// peak and upper normalized coordinates.
|
||||||
|
type regionAxis struct {
|
||||||
|
start, peak, end float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ivData is one ItemVariationData subtable reduced to what a CFF2 blend needs:
|
||||||
|
// the ordered list of region indices whose deltas the blend supplies. The delta
|
||||||
|
// values themselves come from the charstring, not from the store.
|
||||||
|
type ivData struct {
|
||||||
|
regionIndices []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemVarStore is the shared OpenType ItemVariationStore (format 1) referenced
|
||||||
|
// by the Top DICT 'vstore' operator: the region list plus each variation-data
|
||||||
|
// subtable's region ordering.
|
||||||
|
type itemVarStore struct {
|
||||||
|
axisCount int
|
||||||
|
regions [][]regionAxis
|
||||||
|
data []ivData
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIndex2 decodes a CFF2 INDEX (identical to a CFF1 INDEX but with a 32-bit
|
||||||
|
// count) at r's current position, returning each stored object as a sub-slice of
|
||||||
|
// r.b and leaving r positioned just past the INDEX.
|
||||||
|
func parseIndex2(r *reader) ([][]byte, error) {
|
||||||
|
count := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 index count: %w", r.err)
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
offSize := int(r.u8())
|
||||||
|
if offSize < 1 || offSize > 4 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 index: bad offSize %d", offSize)
|
||||||
|
}
|
||||||
|
offsets := make([]int, count+1)
|
||||||
|
for k := range offsets {
|
||||||
|
offsets[k] = readOffset(r, offSize)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 index offsets: %w", r.err)
|
||||||
|
}
|
||||||
|
base := r.pos - offsets[0]
|
||||||
|
items := make([][]byte, count)
|
||||||
|
for k := 0; k < count; k++ {
|
||||||
|
start, end := base+offsets[k], base+offsets[k+1]
|
||||||
|
if start < 0 || end > len(r.b) || start > end {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 index: item %d out of range", k)
|
||||||
|
}
|
||||||
|
items[k] = r.b[start:end]
|
||||||
|
}
|
||||||
|
r.pos = base + offsets[count]
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDict2 decodes a CFF2 DICT into a map from operator to its operand list.
|
||||||
|
// It differs from cff.go's parseDict only in the operator range: CFF2 adds the
|
||||||
|
// single-byte operators vsindex (22) and blend (23) to the Private DICT and
|
||||||
|
// vstore (24) to the Top DICT, all of which are reserved in CFF1. Operand bytes
|
||||||
|
// (28, 29, 30 and 32..255) share cff.go's parseDictOperand decoder.
|
||||||
|
func parseDict2(b []byte) (map[int][]float64, error) {
|
||||||
|
d := map[int][]float64{}
|
||||||
|
var operands []float64
|
||||||
|
i := 0
|
||||||
|
for i < len(b) {
|
||||||
|
b0 := b[i]
|
||||||
|
if b0 < 28 { // operator (12 introduces a two-byte operator)
|
||||||
|
op := int(b0)
|
||||||
|
i++
|
||||||
|
if b0 == 12 {
|
||||||
|
if i >= len(b) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 dict: truncated operator")
|
||||||
|
}
|
||||||
|
op = 1200 + int(b[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
d[op] = operands
|
||||||
|
operands = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, n, err := parseDictOperand(b, i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
operands = append(operands, v)
|
||||||
|
i += n
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCFF2 decodes a CFF2 table: the 5-byte header, the Top DICT, the Global
|
||||||
|
// Subr INDEX, the CharStrings INDEX, the optional ItemVariationStore, and the
|
||||||
|
// FDArray/FDSelect that assign each glyph its Private DICT and Local Subrs.
|
||||||
|
func parseCFF2(data []byte) (*cff2Table, error) {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 header: %w", errTruncated)
|
||||||
|
}
|
||||||
|
if data[0] != 2 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: unsupported major version %d", data[0])
|
||||||
|
}
|
||||||
|
hdrSize := int(data[2])
|
||||||
|
hr := reader{b: data, pos: 3}
|
||||||
|
topDictLen := int(hr.u16())
|
||||||
|
if hdrSize < 5 || hdrSize > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: bad headerSize %d", hdrSize)
|
||||||
|
}
|
||||||
|
if hdrSize+topDictLen > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: Top DICT out of range")
|
||||||
|
}
|
||||||
|
|
||||||
|
top, err := parseDict2(data[hdrSize : hdrSize+topDictLen])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &reader{b: data, pos: hdrSize + topDictLen}
|
||||||
|
gsubrs, err := parseIndex2(r) // Global Subr INDEX
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
csOff, ok := dictInt(top, 17, 0)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: missing CharStrings offset")
|
||||||
|
}
|
||||||
|
if csOff < 0 || csOff > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: CharStrings offset out of range")
|
||||||
|
}
|
||||||
|
charStrings, err := parseIndex2(&reader{b: data, pos: csOff})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var vstore *itemVarStore
|
||||||
|
if vsOff, ok := dictInt(top, 24, 0); ok {
|
||||||
|
if vstore, err = parseItemVarStore(data, vsOff); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localSubrs, localBias, privVsindex, privHints, err := parseCFF2FDArray(data, top)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fdSelect, err := parseCFF2FDSelect(data, top, len(charStrings), len(localSubrs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cff2Table{
|
||||||
|
charStrings: charStrings,
|
||||||
|
globalSubrs: gsubrs,
|
||||||
|
gsubrBias: subrBias(len(gsubrs)),
|
||||||
|
fdSelect: fdSelect,
|
||||||
|
localSubrs: localSubrs,
|
||||||
|
localBias: localBias,
|
||||||
|
privVsindex: privVsindex,
|
||||||
|
privHints: privHints,
|
||||||
|
vstore: vstore,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseItemVarStore decodes the ItemVariationStore at offset off in data, whose
|
||||||
|
// data is prefixed with a 16-bit length. Only format 1 is supported. For CFF2
|
||||||
|
// only the region list and each subtable's region ordering are needed; the
|
||||||
|
// stored delta sets are not read.
|
||||||
|
func parseItemVarStore(data []byte, off int) (*itemVarStore, error) {
|
||||||
|
lr := reader{b: data, pos: off}
|
||||||
|
length := int(lr.u16())
|
||||||
|
if lr.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore length: %w", lr.err)
|
||||||
|
}
|
||||||
|
start := lr.pos
|
||||||
|
if start+length > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore out of range")
|
||||||
|
}
|
||||||
|
ivs := data[start : start+length]
|
||||||
|
|
||||||
|
r := reader{b: ivs}
|
||||||
|
if format := r.u16(); format != 1 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore: unsupported format %d", format)
|
||||||
|
}
|
||||||
|
regionListOff := int(r.u32())
|
||||||
|
ivdCount := int(r.u16())
|
||||||
|
ivdOffsets := make([]int, ivdCount)
|
||||||
|
for i := range ivdOffsets {
|
||||||
|
ivdOffsets[i] = int(r.u32())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore header: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := reader{b: ivs, pos: regionListOff}
|
||||||
|
axisCount := int(rr.u16())
|
||||||
|
regionCount := int(rr.u16())
|
||||||
|
regions := make([][]regionAxis, regionCount)
|
||||||
|
for i := range regions {
|
||||||
|
axes := make([]regionAxis, axisCount)
|
||||||
|
for a := range axes {
|
||||||
|
axes[a].start = rr.f2dot14()
|
||||||
|
axes[a].peak = rr.f2dot14()
|
||||||
|
axes[a].end = rr.f2dot14()
|
||||||
|
}
|
||||||
|
regions[i] = axes
|
||||||
|
}
|
||||||
|
if rr.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore regions: %w", rr.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
datas := make([]ivData, ivdCount)
|
||||||
|
for i, o := range ivdOffsets {
|
||||||
|
dr := reader{b: ivs, pos: o}
|
||||||
|
dr.u16() // itemCount (delta sets are not consumed by blend)
|
||||||
|
dr.u16() // wordDeltaCount
|
||||||
|
regionIndexCount := int(dr.u16())
|
||||||
|
idx := make([]int, regionIndexCount)
|
||||||
|
for k := range idx {
|
||||||
|
ri := int(dr.u16())
|
||||||
|
if ri >= regionCount {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore: region index %d out of range", ri)
|
||||||
|
}
|
||||||
|
idx[k] = ri
|
||||||
|
}
|
||||||
|
if dr.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 vstore data %d: %w", i, dr.err)
|
||||||
|
}
|
||||||
|
datas[i] = ivData{regionIndices: idx}
|
||||||
|
}
|
||||||
|
return &itemVarStore{axisCount: axisCount, regions: regions, data: datas}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionScalar returns how strongly region ri applies at the normalized
|
||||||
|
// coordinate coords, reusing the shared tent-function evaluator (gvar.go).
|
||||||
|
func (vs *itemVarStore) regionScalar(ri int, coords []float64) float64 {
|
||||||
|
reg := vs.regions[ri]
|
||||||
|
lower := make([]float64, len(reg))
|
||||||
|
peak := make([]float64, len(reg))
|
||||||
|
upper := make([]float64, len(reg))
|
||||||
|
for a, ax := range reg {
|
||||||
|
lower[a], peak[a], upper[a] = ax.start, ax.peak, ax.end
|
||||||
|
}
|
||||||
|
return supportScalar(coords, lower, peak, upper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCFF2FDArray decodes the FDArray (an INDEX of Font DICTs) and each Font
|
||||||
|
// DICT's Private DICT, returning the per-Font-DICT Local Subr INDEXes, their
|
||||||
|
// biases and their default vsindex values. A CFF2 table without an FDArray is
|
||||||
|
// treated as having a single Font DICT with no Local Subrs.
|
||||||
|
func parseCFF2FDArray(data []byte, top map[int][]float64) (subrs [][][]byte, bias, vsindex []int, hints []*cffPrivate, err error) {
|
||||||
|
off, ok := dictInt(top, 1236, 0)
|
||||||
|
if !ok {
|
||||||
|
return [][][]byte{nil}, []int{subrBias(0)}, []int{0}, []*cffPrivate{nil}, nil
|
||||||
|
}
|
||||||
|
if off < 0 || off > len(data) {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("opentype: cff2: FDArray offset out of range")
|
||||||
|
}
|
||||||
|
fontDicts, err := parseIndex2(&reader{b: data, pos: off})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if len(fontDicts) == 0 {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("opentype: cff2: empty FDArray")
|
||||||
|
}
|
||||||
|
subrs = make([][][]byte, len(fontDicts))
|
||||||
|
bias = make([]int, len(fontDicts))
|
||||||
|
vsindex = make([]int, len(fontDicts))
|
||||||
|
hints = make([]*cffPrivate, len(fontDicts))
|
||||||
|
for i, fd := range fontDicts {
|
||||||
|
d, err := parseDict2(fd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
ls, vs, h, err := parseCFF2Private(data, d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
subrs[i] = ls
|
||||||
|
bias[i] = subrBias(len(ls))
|
||||||
|
vsindex[i] = vs
|
||||||
|
hints[i] = h
|
||||||
|
}
|
||||||
|
return subrs, bias, vsindex, hints, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCFF2Private resolves a Font DICT's Private DICT and, if present, its
|
||||||
|
// Local Subr INDEX and default vsindex, and parses its hint parameters (blue
|
||||||
|
// zones, standard/snap stem widths) for the grid-fitter (cffhint.go).
|
||||||
|
func parseCFF2Private(data []byte, fd map[int][]float64) ([][]byte, int, *cffPrivate, error) {
|
||||||
|
pv, ok := fd[18]
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, nil, nil
|
||||||
|
}
|
||||||
|
if len(pv) < 2 {
|
||||||
|
return nil, 0, nil, fmt.Errorf("opentype: cff2: bad Private DICT entry")
|
||||||
|
}
|
||||||
|
psize, poff := int(pv[0]), int(pv[1])
|
||||||
|
if psize < 0 || poff < 0 || poff+psize > len(data) {
|
||||||
|
return nil, 0, nil, fmt.Errorf("opentype: cff2: Private DICT out of range")
|
||||||
|
}
|
||||||
|
priv, err := parseDict2(data[poff : poff+psize])
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, err
|
||||||
|
}
|
||||||
|
hints := parsePrivateHints(priv)
|
||||||
|
vsindex := 0
|
||||||
|
if v, ok := dictInt(priv, 22, 0); ok {
|
||||||
|
vsindex = v
|
||||||
|
}
|
||||||
|
soff, ok := dictInt(priv, 19, 0)
|
||||||
|
if !ok {
|
||||||
|
return nil, vsindex, hints, nil
|
||||||
|
}
|
||||||
|
soff += poff
|
||||||
|
if soff < 0 || soff > len(data) {
|
||||||
|
return nil, 0, nil, fmt.Errorf("opentype: cff2: Local Subrs offset out of range")
|
||||||
|
}
|
||||||
|
ls, err := parseIndex2(&reader{b: data, pos: soff})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, err
|
||||||
|
}
|
||||||
|
return ls, vsindex, hints, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCFF2FDSelect decodes the FDSelect (glyph -> Font DICT index) in format 0
|
||||||
|
// or 3. A table without an FDSelect returns nil (every glyph uses Font DICT 0).
|
||||||
|
func parseCFF2FDSelect(data []byte, top map[int][]float64, numGlyphs, numFDs int) ([]int, error) {
|
||||||
|
off, ok := dictInt(top, 1237, 0)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if off < 0 || off >= len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: FDSelect offset out of range")
|
||||||
|
}
|
||||||
|
r := reader{b: data, pos: off}
|
||||||
|
format := r.u8()
|
||||||
|
sel := make([]int, numGlyphs)
|
||||||
|
switch format {
|
||||||
|
case 0:
|
||||||
|
for g := 0; g < numGlyphs; g++ {
|
||||||
|
fd := int(r.u8())
|
||||||
|
if fd >= numFDs {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: FDSelect fd %d out of range", fd)
|
||||||
|
}
|
||||||
|
sel[g] = fd
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 FDSelect: %w", r.err)
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
nRanges := int(r.u16())
|
||||||
|
firsts := make([]int, nRanges)
|
||||||
|
fds := make([]int, nRanges)
|
||||||
|
for i := 0; i < nRanges; i++ {
|
||||||
|
firsts[i] = int(r.u16())
|
||||||
|
fds[i] = int(r.u8())
|
||||||
|
}
|
||||||
|
sentinel := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2 FDSelect: %w", r.err)
|
||||||
|
}
|
||||||
|
for i := 0; i < nRanges; i++ {
|
||||||
|
if fds[i] >= numFDs {
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: FDSelect fd %d out of range", fds[i])
|
||||||
|
}
|
||||||
|
next := sentinel
|
||||||
|
if i+1 < nRanges {
|
||||||
|
next = firsts[i+1]
|
||||||
|
}
|
||||||
|
for g := firsts[i]; g < next && g < numGlyphs; g++ {
|
||||||
|
sel[g] = fds[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: cff2: unsupported FDSelect format %d", format)
|
||||||
|
}
|
||||||
|
return sel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cff2machine interprets one glyph's CFF2 charstring. It borrows a t2machine as
|
||||||
|
// its pen (pen position, contour under construction, finished contours and the
|
||||||
|
// Bézier-flattening path operators) and adds the CFF2-only variation state: the
|
||||||
|
// normalized coordinate, the active variation-store index and the blend vector
|
||||||
|
// (the per-region scalars, with a leading 1 for the default master).
|
||||||
|
type cff2machine struct {
|
||||||
|
c *cff2Table
|
||||||
|
tm *t2machine
|
||||||
|
localSubrs [][]byte
|
||||||
|
localBias int
|
||||||
|
coords []float64
|
||||||
|
vsindex int
|
||||||
|
bv []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// outline interprets glyph gid's CFF2 charstring at the normalized variation
|
||||||
|
// coordinate coords (F2Dot14 int16 per axis, as produced by NormalizeCoords);
|
||||||
|
// a nil or short coords vector leaves the remaining axes at their default. The
|
||||||
|
// result is the instanced outline as contours in font units, all points
|
||||||
|
// on-curve with cubics pre-flattened.
|
||||||
|
func (c *cff2Table) outline(gid int, coords []int16) ([]contour, error) {
|
||||||
|
cs, _, err := c.outlineHints(gid, coords)
|
||||||
|
return cs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// outlineHints is outline plus the hinting data (recorded stems, per-hintmask
|
||||||
|
// activation and the glyph's Font-DICT Private-DICT hint parameters) that
|
||||||
|
// cffhint.go's grid-fitter consumes. The outline is still instanced at coords.
|
||||||
|
func (c *cff2Table) outlineHints(gid int, coords []int16) ([]contour, *cffGlyphHints, error) {
|
||||||
|
if gid < 0 || gid >= len(c.charStrings) {
|
||||||
|
return nil, nil, fmt.Errorf("opentype: cff2 glyph %d out of range", gid)
|
||||||
|
}
|
||||||
|
fd := 0
|
||||||
|
if c.fdSelect != nil {
|
||||||
|
fd = c.fdSelect[gid]
|
||||||
|
}
|
||||||
|
axisCount := 0
|
||||||
|
if c.vstore != nil {
|
||||||
|
axisCount = c.vstore.axisCount
|
||||||
|
}
|
||||||
|
m := &cff2machine{
|
||||||
|
c: c,
|
||||||
|
tm: &t2machine{},
|
||||||
|
localSubrs: c.localSubrs[fd],
|
||||||
|
localBias: c.localBias[fd],
|
||||||
|
coords: make([]float64, axisCount),
|
||||||
|
vsindex: c.privVsindex[fd],
|
||||||
|
}
|
||||||
|
for a := 0; a < axisCount && a < len(coords); a++ {
|
||||||
|
m.coords[a] = float64(coords[a]) / 16384.0
|
||||||
|
}
|
||||||
|
if err := m.computeBV(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := m.run(c.charStrings[gid], 0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
m.tm.finishContour()
|
||||||
|
gh := &cffGlyphHints{
|
||||||
|
stems: m.tm.stemHints,
|
||||||
|
hintMasks: m.tm.hintMasks,
|
||||||
|
cntrGroups: m.tm.cntrGroups,
|
||||||
|
flexRanges: m.tm.flexRanges,
|
||||||
|
priv: c.privHints[fd],
|
||||||
|
}
|
||||||
|
return m.tm.contours, gh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeBV rebuilds the blend vector for the active vsindex: a leading 1 (the
|
||||||
|
// default master's scalar) followed by one scalar per region of the selected
|
||||||
|
// ItemVariationData. With no variation store the vector is just [1], so blend
|
||||||
|
// becomes a no-op that leaves its default operands unchanged.
|
||||||
|
func (m *cff2machine) computeBV() error {
|
||||||
|
m.bv = append(m.bv[:0], 1)
|
||||||
|
vs := m.c.vstore
|
||||||
|
if vs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m.vsindex < 0 || m.vsindex >= len(vs.data) {
|
||||||
|
return fmt.Errorf("opentype: cff2: vsindex %d out of range", m.vsindex)
|
||||||
|
}
|
||||||
|
for _, ri := range vs.data[m.vsindex].regionIndices {
|
||||||
|
m.bv = append(m.bv, vs.regionScalar(ri, m.coords))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// push appends v to the operand stack, guarding the CFF2 stack-depth limit.
|
||||||
|
func (m *cff2machine) push(v float64) error {
|
||||||
|
if len(m.tm.stack) >= maxCFF2Stack {
|
||||||
|
return fmt.Errorf("opentype: cff2: charstring stack overflow")
|
||||||
|
}
|
||||||
|
m.tm.stack = append(m.tm.stack, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes a charstring (or subroutine) body; depth guards recursion. It
|
||||||
|
// returns when the code is exhausted or a 'return' operator ends a subroutine.
|
||||||
|
func (m *cff2machine) run(code []byte, depth int) error {
|
||||||
|
if depth > maxT2Depth {
|
||||||
|
return fmt.Errorf("opentype: cff2: subroutine recursion too deep")
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
for i < len(code) {
|
||||||
|
b0 := code[i]
|
||||||
|
i++
|
||||||
|
if b0 >= 32 || b0 == 28 {
|
||||||
|
v, n, err := m.tm.operand(code, i-1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.push(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i += n - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ni, ret, err := m.operator(b0, code, i, depth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i = ni
|
||||||
|
if ret {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operator executes one CFF2 operator byte b0. code/i give the stream position
|
||||||
|
// just past b0 (for hintmask bytes and the escape second byte); it returns the
|
||||||
|
// updated stream index, whether the current subroutine should stop ('return'),
|
||||||
|
// and any error. Unlike Type2 there is no leading-width handling.
|
||||||
|
func (m *cff2machine) operator(b0 byte, code []byte, i, depth int) (int, bool, error) {
|
||||||
|
tm := m.tm
|
||||||
|
switch b0 {
|
||||||
|
case 1, 3, 18, 23: // hstem, vstem, hstemhm, vstemhm
|
||||||
|
tm.recordStems(b0 == 1 || b0 == 18)
|
||||||
|
case 19, 20: // hintmask, cntrmask
|
||||||
|
tm.recordStems(false) // any operands here are implicit vstem hints
|
||||||
|
ni, herr := tm.readHintMask(code, i, b0 == 19)
|
||||||
|
if herr != nil {
|
||||||
|
return i, false, herr
|
||||||
|
}
|
||||||
|
i = ni
|
||||||
|
case 21: // rmoveto
|
||||||
|
if len(tm.stack) < 2 {
|
||||||
|
return i, false, fmt.Errorf("opentype: cff2: rmoveto needs 2 args")
|
||||||
|
}
|
||||||
|
n := len(tm.stack)
|
||||||
|
tm.moveTo(tm.stack[n-2], tm.stack[n-1])
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 22: // hmoveto
|
||||||
|
if len(tm.stack) < 1 {
|
||||||
|
return i, false, fmt.Errorf("opentype: cff2: hmoveto needs 1 arg")
|
||||||
|
}
|
||||||
|
tm.moveTo(tm.stack[len(tm.stack)-1], 0)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 4: // vmoveto
|
||||||
|
if len(tm.stack) < 1 {
|
||||||
|
return i, false, fmt.Errorf("opentype: cff2: vmoveto needs 1 arg")
|
||||||
|
}
|
||||||
|
tm.moveTo(0, tm.stack[len(tm.stack)-1])
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 5: // rlineto
|
||||||
|
s := tm.stack
|
||||||
|
for j := 0; j+2 <= len(s); j += 2 {
|
||||||
|
tm.lineTo(s[j], s[j+1])
|
||||||
|
}
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 6: // hlineto
|
||||||
|
tm.altLine(true)
|
||||||
|
case 7: // vlineto
|
||||||
|
tm.altLine(false)
|
||||||
|
case 8: // rrcurveto
|
||||||
|
tm.rrcurveto(tm.stack)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 24: // rcurveline
|
||||||
|
tm.rcurveline(tm.stack)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 25: // rlinecurve
|
||||||
|
tm.rlinecurve(tm.stack)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 26: // vvcurveto
|
||||||
|
tm.vvcurveto(tm.stack)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 27: // hhcurveto
|
||||||
|
tm.hhcurveto(tm.stack)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 30: // vhcurveto
|
||||||
|
tm.altCurve(tm.stack, false)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 31: // hvcurveto
|
||||||
|
tm.altCurve(tm.stack, true)
|
||||||
|
tm.stack = tm.stack[:0]
|
||||||
|
case 10: // callsubr
|
||||||
|
if err := m.callSubr(m.localSubrs, m.localBias, depth); err != nil {
|
||||||
|
return i, false, err
|
||||||
|
}
|
||||||
|
case 29: // callgsubr
|
||||||
|
if err := m.callSubr(m.c.globalSubrs, m.c.gsubrBias, depth); err != nil {
|
||||||
|
return i, false, err
|
||||||
|
}
|
||||||
|
case 11: // return
|
||||||
|
return i, true, nil
|
||||||
|
case 15: // vsindex
|
||||||
|
if err := m.vsindexOp(); err != nil {
|
||||||
|
return i, false, err
|
||||||
|
}
|
||||||
|
case 16: // blend
|
||||||
|
if err := m.blendOp(); err != nil {
|
||||||
|
return i, false, err
|
||||||
|
}
|
||||||
|
case 12: // escape: two-byte operator (flex family)
|
||||||
|
if i >= len(code) {
|
||||||
|
return i, false, fmt.Errorf("opentype: cff2 charstring: truncated escape")
|
||||||
|
}
|
||||||
|
b1 := code[i]
|
||||||
|
i++
|
||||||
|
if err := tm.escape(b1); err != nil {
|
||||||
|
return i, false, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return i, false, fmt.Errorf("opentype: cff2: unknown operator %d", b0)
|
||||||
|
}
|
||||||
|
return i, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callSubr executes the subroutine selected by the top-of-stack index (plus the
|
||||||
|
// bias) from subrs. A 'return' inside the subroutine ends only that subroutine.
|
||||||
|
func (m *cff2machine) callSubr(subrs [][]byte, bias, depth int) error {
|
||||||
|
st := m.tm.stack
|
||||||
|
if len(st) < 1 {
|
||||||
|
return fmt.Errorf("opentype: cff2: callsubr needs an index")
|
||||||
|
}
|
||||||
|
idx := int(st[len(st)-1]) + bias
|
||||||
|
m.tm.stack = st[:len(st)-1]
|
||||||
|
if idx < 0 || idx >= len(subrs) {
|
||||||
|
return fmt.Errorf("opentype: cff2: subr index %d out of range", idx)
|
||||||
|
}
|
||||||
|
return m.run(subrs[idx], depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vsindexOp handles the vsindex operator: it selects the ItemVariationData that
|
||||||
|
// subsequent blends draw from and recomputes the blend vector.
|
||||||
|
func (m *cff2machine) vsindexOp() error {
|
||||||
|
st := m.tm.stack
|
||||||
|
if len(st) < 1 {
|
||||||
|
return fmt.Errorf("opentype: cff2: vsindex needs 1 arg")
|
||||||
|
}
|
||||||
|
m.vsindex = int(st[len(st)-1])
|
||||||
|
m.tm.stack = st[:0]
|
||||||
|
return m.computeBV()
|
||||||
|
}
|
||||||
|
|
||||||
|
// blendOp handles the blend operator: the top operand n gives the number of
|
||||||
|
// resulting values; below it are n default values followed by n groups of k
|
||||||
|
// region deltas (k = regions of the active vsindex). Each default value is
|
||||||
|
// replaced in place by default + Σ bv[region]·delta, and the deltas and n are
|
||||||
|
// popped, leaving the n blended values as operands for the following operator.
|
||||||
|
func (m *cff2machine) blendOp() error {
|
||||||
|
st := m.tm.stack
|
||||||
|
if len(st) < 1 {
|
||||||
|
return fmt.Errorf("opentype: cff2: blend needs a count")
|
||||||
|
}
|
||||||
|
numBlends := int(st[len(st)-1])
|
||||||
|
st = st[:len(st)-1]
|
||||||
|
if numBlends < 0 {
|
||||||
|
return fmt.Errorf("opentype: cff2: blend count %d invalid", numBlends)
|
||||||
|
}
|
||||||
|
k := len(m.bv) - 1
|
||||||
|
need := numBlends * (k + 1)
|
||||||
|
if need > len(st) {
|
||||||
|
return fmt.Errorf("opentype: cff2: blend needs %d operands, have %d", need, len(st))
|
||||||
|
}
|
||||||
|
base := len(st) - need
|
||||||
|
di := base + numBlends
|
||||||
|
for vi := 0; vi < numBlends; vi++ {
|
||||||
|
sum := st[base+vi]
|
||||||
|
for j := 1; j <= k; j++ {
|
||||||
|
sum += m.bv[j] * st[di]
|
||||||
|
di++
|
||||||
|
}
|
||||||
|
st[base+vi] = sum
|
||||||
|
}
|
||||||
|
m.tm.stack = st[:base+numBlends]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+649
@@ -0,0 +1,649 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file grid-fits CFF/CFF2 (Type2) outlines the way FreeType's CFF hinter
|
||||||
|
// does: it aligns stem edges and blue-zone reference points to the pixel grid
|
||||||
|
// at a target ppem, then interpolates the rest of the outline between them. It
|
||||||
|
// is the CFF counterpart of the TrueType instruction interpreter (hint.go);
|
||||||
|
// where TrueType grid-fitting is imperative (a bytecode VM moves points), CFF
|
||||||
|
// grid-fitting is declarative — the font only states its stems (recorded by the
|
||||||
|
// Type2 interpreter in cff.go/cff2.go) and its Private-DICT hint parameters
|
||||||
|
// (blue zones, standard and snap stem widths, parsed here), and the renderer
|
||||||
|
// decides how to snap them.
|
||||||
|
//
|
||||||
|
// Pipeline (per glyph, per axis, independent x and y):
|
||||||
|
// 1. Scale every stem edge and blue-zone edge to device space (pixels/em).
|
||||||
|
// 2. Snap each active stem's width toward StdHW/StdVW / StemSnap and round it,
|
||||||
|
// then position the stem so one edge lands on an integer pixel boundary,
|
||||||
|
// anchoring horizontal (hstem) edges that fall in a blue zone to the rounded
|
||||||
|
// zone position instead. This yields, per stem edge, an original device
|
||||||
|
// position and its hinted (grid-fitted) position.
|
||||||
|
// 3. Build a piecewise-linear control map from those (original -> hinted)
|
||||||
|
// edge pairs plus a blue-zone flat-edge anchor per zone, and remap every
|
||||||
|
// outline point: a point on a control edge snaps with it, a point between
|
||||||
|
// two edges interpolates proportionally, and a point outside the outermost
|
||||||
|
// edges shifts rigidly with the nearest one. This is the same interpolation
|
||||||
|
// TrueType uses for untouched points (IUP), applied here to the whole
|
||||||
|
// outline.
|
||||||
|
//
|
||||||
|
// What is implemented: Private-DICT hint-parameter parsing (BlueValues,
|
||||||
|
// OtherBlues, FamilyBlues, FamilyOtherBlues, BlueScale, BlueShift, BlueFuzz,
|
||||||
|
// StdHW, StdVW, StemSnapH, StemSnapV); top/bottom blue-zone construction with
|
||||||
|
// overshoot, family-blue substitution and the BlueScale overshoot-suppression
|
||||||
|
// rule; stem-width snapping and edge grid-fitting; blue-zone alignment of stem
|
||||||
|
// edges and reference points; and per-point hintmask hint selection.
|
||||||
|
//
|
||||||
|
// The finer FreeType sub-nuances are also applied here:
|
||||||
|
// - counter control: a cntrmask hint group's counters (the gaps between the
|
||||||
|
// group's same-orientation stems, e.g. the three verticals of 'm') are
|
||||||
|
// equalised after grid-fitting so the stems keep even spacing;
|
||||||
|
// - mid-subpath hintmask changes: each point is remapped through the control
|
||||||
|
// map built from the stems active at that point (not the subpath's first
|
||||||
|
// point), so a mask that switches mid-contour hints each portion correctly;
|
||||||
|
// - flex regions: the near-flat span a flex (flex/flex1/hflex/hflex1) draws is
|
||||||
|
// interpolated smoothly between its grid-fitted anchors instead of letting an
|
||||||
|
// interior point snap to a stem edge as though it were a straight stem;
|
||||||
|
// - stem darkening: an optional ppem-dependent emboldening of stem edges that
|
||||||
|
// improves contrast on the anti-aliased rasteriser at small sizes (off by
|
||||||
|
// default, toggled by Face.SetStemDarkening; the output is byte-identical to
|
||||||
|
// the undarkened result when it is off).
|
||||||
|
|
||||||
|
// cffStemHint is one resolved stem hint in font units: the two edge positions
|
||||||
|
// (min < max) and whether it is a horizontal stem (hstem, edges are Y
|
||||||
|
// positions, controlling horizontal features and blue alignment) or a vertical
|
||||||
|
// stem (vstem, edges are X positions).
|
||||||
|
type cffStemHint struct {
|
||||||
|
horizontal bool
|
||||||
|
min, max float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffHintMask is one recorded hintmask activation: the outline-point index at
|
||||||
|
// which it takes effect (so per-subpath hint selection can find the mask in
|
||||||
|
// force for a given point) and, per declared stem, whether that stem is active.
|
||||||
|
type cffHintMask struct {
|
||||||
|
pointIndex int
|
||||||
|
active []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexRange is one flex region's outline-point span (inclusive global point
|
||||||
|
// indices of the points the two flex curves emitted). The point at start-1 is
|
||||||
|
// the flex's entry anchor and the point at end is its exit anchor; the interior
|
||||||
|
// points between them are interpolated smoothly by the grid-fitter.
|
||||||
|
type flexRange struct {
|
||||||
|
start, end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffGlyphHints bundles a glyph's recorded stems, hintmask activations,
|
||||||
|
// counter-control (cntrmask) stem groups and flex-region point spans with the
|
||||||
|
// font's parsed Private-DICT hint parameters, as returned by the CFF/CFF2
|
||||||
|
// interpreters alongside the outline.
|
||||||
|
type cffGlyphHints struct {
|
||||||
|
stems []cffStemHint
|
||||||
|
hintMasks []cffHintMask
|
||||||
|
cntrGroups [][]int
|
||||||
|
flexRanges []flexRange
|
||||||
|
priv *cffPrivate
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSelAt returns the index into hintMasks of the hintmask in force at the
|
||||||
|
// given outline-point index — the last hintmask whose pointIndex does not exceed
|
||||||
|
// it — or -1 when none applies yet (every stem is then treated as active). It is
|
||||||
|
// the primitive the grid-fitter keys its per-point control maps on so a hintmask
|
||||||
|
// that changes mid-subpath selects the right stems at each point.
|
||||||
|
func (gh *cffGlyphHints) maskSelAt(pointIndex int) int {
|
||||||
|
sel := -1
|
||||||
|
for i, hm := range gh.hintMasks {
|
||||||
|
if hm.pointIndex <= pointIndex {
|
||||||
|
sel = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeMaskAt returns the per-stem activation of the hintmask in force at the
|
||||||
|
// given outline-point index. A nil result means "all stems active" (no hintmask
|
||||||
|
// applies yet, or the glyph declared none), which the grid-fitter treats as
|
||||||
|
// every stem on.
|
||||||
|
func (gh *cffGlyphHints) activeMaskAt(pointIndex int) []bool {
|
||||||
|
sel := gh.maskSelAt(pointIndex)
|
||||||
|
if sel < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return gh.hintMasks[sel].active
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffPrivate holds the Private-DICT hint parameters that drive grid-fitting.
|
||||||
|
// Blue arrays are stored already accumulated (the DICT delta encoding undone)
|
||||||
|
// and in font units; BlueScale/BlueShift/BlueFuzz carry their CFF defaults when
|
||||||
|
// the DICT omits them.
|
||||||
|
type cffPrivate struct {
|
||||||
|
blueValues []float64 // paired: [0:2] baseline (bottom) zone, rest top zones
|
||||||
|
otherBlues []float64 // paired: additional bottom zones
|
||||||
|
familyBlues []float64 // paired: family baseline+top zones (consistency)
|
||||||
|
familyOtherBlues []float64 // paired: family bottom zones
|
||||||
|
blueScale float64 // overshoot-suppression threshold (default 0.039625)
|
||||||
|
blueShift float64 // minimum overshoot before it is shown (default 7)
|
||||||
|
blueFuzz float64 // blue-zone capture extension (default 1)
|
||||||
|
stdHW float64 // standard horizontal-stem width (hstem)
|
||||||
|
stdVW float64 // standard vertical-stem width (vstem)
|
||||||
|
stemSnapH []float64 // horizontal-stem snap widths
|
||||||
|
stemSnapV []float64 // vertical-stem snap widths
|
||||||
|
defaultWidthX float64 // the advance of a glyph whose charstring gives none
|
||||||
|
nominalWidthX float64 // what a charstring's width is measured from
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFF Private-DICT defaults for the overshoot-control parameters (CFF spec).
|
||||||
|
const (
|
||||||
|
defaultBlueScale = 0.039625
|
||||||
|
defaultBlueShift = 7
|
||||||
|
defaultBlueFuzz = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// snapTolerancePx is how close (in device pixels) a stem's scaled width must be
|
||||||
|
// to a standard/snap width for the width to be rendered as that standard width
|
||||||
|
// (unifying near-equal stems) before rounding to whole pixels.
|
||||||
|
const snapTolerancePx = 0.5
|
||||||
|
|
||||||
|
// parsePrivateHints extracts the hint parameters from an already-decoded
|
||||||
|
// Private DICT (CFF or CFF2 share the operator codes). Absent overshoot-control
|
||||||
|
// operators take their CFF defaults; the delta-encoded blue and stem-snap
|
||||||
|
// arrays are accumulated into absolute font-unit values.
|
||||||
|
func parsePrivateHints(d map[int][]float64) *cffPrivate {
|
||||||
|
p := &cffPrivate{
|
||||||
|
blueScale: defaultBlueScale,
|
||||||
|
blueShift: defaultBlueShift,
|
||||||
|
blueFuzz: defaultBlueFuzz,
|
||||||
|
}
|
||||||
|
p.blueValues = accumDelta(d[6])
|
||||||
|
p.otherBlues = accumDelta(d[7])
|
||||||
|
p.familyBlues = accumDelta(d[8])
|
||||||
|
p.familyOtherBlues = accumDelta(d[9])
|
||||||
|
if v, ok := dictFloat(d, 10); ok {
|
||||||
|
p.stdHW = v
|
||||||
|
}
|
||||||
|
if v, ok := dictFloat(d, 11); ok {
|
||||||
|
p.stdVW = v
|
||||||
|
}
|
||||||
|
if v, ok := dictFloat(d, 1209); ok {
|
||||||
|
p.blueScale = v
|
||||||
|
}
|
||||||
|
if v, ok := dictFloat(d, 1210); ok {
|
||||||
|
p.blueShift = v
|
||||||
|
}
|
||||||
|
if v, ok := dictFloat(d, 1211); ok {
|
||||||
|
p.blueFuzz = v
|
||||||
|
}
|
||||||
|
p.stemSnapH = accumDelta(d[1212])
|
||||||
|
p.stemSnapV = accumDelta(d[1213])
|
||||||
|
if v, ok := d[20]; ok && len(v) > 0 {
|
||||||
|
p.defaultWidthX = v[0]
|
||||||
|
}
|
||||||
|
if v, ok := d[21]; ok && len(v) > 0 {
|
||||||
|
p.nominalWidthX = v[0]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// dictFloat returns the first operand of operator op in d as a float, or
|
||||||
|
// ok=false when the operator is absent or carries no operand.
|
||||||
|
func dictFloat(d map[int][]float64, op int) (float64, bool) {
|
||||||
|
v, ok := d[op]
|
||||||
|
if !ok || len(v) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// accumDelta undoes the CFF DICT "delta" encoding: the stored operands are
|
||||||
|
// successive differences, so the absolute values are their running sum. A nil
|
||||||
|
// or empty input yields nil.
|
||||||
|
func accumDelta(v []float64) []float64 {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]float64, len(v))
|
||||||
|
sum := 0.0
|
||||||
|
for i, d := range v {
|
||||||
|
sum += d
|
||||||
|
out[i] = sum
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// blueZone is one alignment zone in font units: flat is the reference edge that
|
||||||
|
// snaps to the grid (the baseline, x-height, cap height, …); over is the
|
||||||
|
// overshoot extent (below flat for a bottom zone, above for a top zone) where
|
||||||
|
// round glyph features spill past the flat edge.
|
||||||
|
type blueZone struct {
|
||||||
|
flat float64
|
||||||
|
over float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeZone builds a zone from a (a, b) blue pair. A top zone's flat edge is the
|
||||||
|
// lower value with overshoot above; a bottom zone's flat edge is the upper
|
||||||
|
// value with overshoot below.
|
||||||
|
func makeZone(a, b float64, isTop bool) blueZone {
|
||||||
|
lo, hi := math.Min(a, b), math.Max(a, b)
|
||||||
|
if isTop {
|
||||||
|
return blueZone{flat: lo, over: hi}
|
||||||
|
}
|
||||||
|
return blueZone{flat: hi, over: lo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFamily substitutes the corresponding FamilyBlues flat edge for a zone's
|
||||||
|
// flat edge when the two are within one device pixel, so members of a type
|
||||||
|
// family align identically at small sizes (the CFF FamilyBlues rule). fam is
|
||||||
|
// the family array paired the same way as the primary array; k is the pair
|
||||||
|
// index.
|
||||||
|
func applyFamily(z *blueZone, fam []float64, k int, isTop bool, scale float64) {
|
||||||
|
if k+1 >= len(fam) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fz := makeZone(fam[k], fam[k+1], isTop)
|
||||||
|
if math.Abs((fz.flat-z.flat)*scale) < 1 {
|
||||||
|
z.flat = fz.flat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBlueZones assembles the blue zones from the Private-DICT arrays: the
|
||||||
|
// first BlueValues pair is the baseline (a bottom zone), the remaining
|
||||||
|
// BlueValues pairs are top zones, and every OtherBlues pair is a bottom zone.
|
||||||
|
// Family blues substitute for consistency (applyFamily).
|
||||||
|
func buildBlueZones(p *cffPrivate, scale float64) []blueZone {
|
||||||
|
var zones []blueZone
|
||||||
|
bv := p.blueValues
|
||||||
|
for k := 0; k+1 < len(bv); k += 2 {
|
||||||
|
isTop := k >= 2
|
||||||
|
z := makeZone(bv[k], bv[k+1], isTop)
|
||||||
|
applyFamily(&z, p.familyBlues, k, isTop, scale)
|
||||||
|
zones = append(zones, z)
|
||||||
|
}
|
||||||
|
ob := p.otherBlues
|
||||||
|
for k := 0; k+1 < len(ob); k += 2 {
|
||||||
|
z := makeZone(ob[k], ob[k+1], false)
|
||||||
|
applyFamily(&z, p.familyOtherBlues, k, false, scale)
|
||||||
|
zones = append(zones, z)
|
||||||
|
}
|
||||||
|
return zones
|
||||||
|
}
|
||||||
|
|
||||||
|
// capture reports whether edgeDev (a device-space stem edge) falls in this zone
|
||||||
|
// and, if so, the device position it should be aligned to. Within BlueFuzz of
|
||||||
|
// the zone the edge is captured; it snaps to the rounded flat edge unless
|
||||||
|
// overshoot is enabled (not suppressed) and the edge lies at least BlueShift
|
||||||
|
// beyond the flat edge, in which case a rounded overshoot of at least one pixel
|
||||||
|
// is preserved.
|
||||||
|
func (z blueZone) capture(edgeDev, scale, blueShift, blueFuzz float64, suppress bool) (float64, bool) {
|
||||||
|
flatDev := z.flat * scale
|
||||||
|
overDev := z.over * scale
|
||||||
|
lo, hi := math.Min(flatDev, overDev), math.Max(flatDev, overDev)
|
||||||
|
fuzz := blueFuzz * scale
|
||||||
|
if edgeDev < lo-fuzz || edgeDev > hi+fuzz {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
base := math.Round(flatDev)
|
||||||
|
if suppress {
|
||||||
|
return base, true
|
||||||
|
}
|
||||||
|
over := edgeDev - flatDev
|
||||||
|
if math.Abs(over) < blueShift*scale {
|
||||||
|
return base, true
|
||||||
|
}
|
||||||
|
o := math.Round(math.Abs(over))
|
||||||
|
if o < 1 {
|
||||||
|
o = 1
|
||||||
|
}
|
||||||
|
if over < 0 {
|
||||||
|
o = -o
|
||||||
|
}
|
||||||
|
return base + o, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintedStem is one stem after grid-fitting: the original device-space edge
|
||||||
|
// positions (oLo < oHi) and their hinted (snapped) positions.
|
||||||
|
type hintedStem struct {
|
||||||
|
oLo, oHi float64
|
||||||
|
hLo, hHi float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintStem grid-fits one stem: its width is snapped toward the standard/snap
|
||||||
|
// widths and rounded; for a horizontal stem an edge that lands in a blue zone
|
||||||
|
// is anchored to the rounded zone position (and the opposite edge placed one
|
||||||
|
// snapped width away), otherwise the lower edge is rounded to the pixel grid.
|
||||||
|
func hintStem(s cffStemHint, scale float64, p *cffPrivate, blues []blueZone, suppress bool) hintedStem {
|
||||||
|
lo := math.Min(s.min, s.max) * scale
|
||||||
|
hi := math.Max(s.min, s.max) * scale
|
||||||
|
std, snaps := p.stdVW, p.stemSnapV
|
||||||
|
if s.horizontal {
|
||||||
|
std, snaps = p.stdHW, p.stemSnapH
|
||||||
|
}
|
||||||
|
w := snapWidth(hi-lo, std*scale, snaps, scale)
|
||||||
|
if s.horizontal {
|
||||||
|
if t, ok := captureEdge(hi, blues, p, scale, suppress); ok {
|
||||||
|
return hintedStem{oLo: lo, oHi: hi, hLo: t - w, hHi: t}
|
||||||
|
}
|
||||||
|
if t, ok := captureEdge(lo, blues, p, scale, suppress); ok {
|
||||||
|
return hintedStem{oLo: lo, oHi: hi, hLo: t, hHi: t + w}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hLo := math.Round(lo)
|
||||||
|
return hintedStem{oLo: lo, oHi: hi, hLo: hLo, hHi: hLo + w}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureEdge returns the aligned device position for a horizontal-stem edge
|
||||||
|
// that falls in one of the blue zones, testing each zone in turn.
|
||||||
|
func captureEdge(edgeDev float64, blues []blueZone, p *cffPrivate, scale float64, suppress bool) (float64, bool) {
|
||||||
|
for _, z := range blues {
|
||||||
|
if t, ok := z.capture(edgeDev, scale, p.blueShift, p.blueFuzz, suppress); ok {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapWidth rounds a scaled stem width to whole pixels, first snapping it to the
|
||||||
|
// nearest standard or snap width (std and snaps are in font units, scaled here)
|
||||||
|
// when within snapTolerancePx so near-equal stems render at one consistent
|
||||||
|
// width. The result is at least one pixel.
|
||||||
|
func snapWidth(w, stdDev float64, snaps []float64, scale float64) float64 {
|
||||||
|
best, bestD := w, math.Inf(1)
|
||||||
|
consider := func(v float64) {
|
||||||
|
if v <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d := math.Abs(w - v); d < bestD {
|
||||||
|
best, bestD = v, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consider(stdDev)
|
||||||
|
for _, s := range snaps {
|
||||||
|
consider(s * scale)
|
||||||
|
}
|
||||||
|
if bestD > snapTolerancePx {
|
||||||
|
best = w
|
||||||
|
}
|
||||||
|
r := math.Round(best)
|
||||||
|
if r < 1 {
|
||||||
|
r = 1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// controlMap is a piecewise-linear 1-D remap built from (original -> hinted)
|
||||||
|
// device positions, sorted and deduplicated by original position.
|
||||||
|
type controlMap struct {
|
||||||
|
orig []float64
|
||||||
|
hint []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// add records one control point (an original device position and where it is
|
||||||
|
// hinted to).
|
||||||
|
func (cm *controlMap) add(o, h float64) {
|
||||||
|
cm.orig = append(cm.orig, o)
|
||||||
|
cm.hint = append(cm.hint, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalize sorts the control points by original position and merges points that
|
||||||
|
// share an original position (averaging their hinted targets), leaving a
|
||||||
|
// strictly increasing orig sequence for interpolation.
|
||||||
|
func (cm *controlMap) finalize() {
|
||||||
|
n := len(cm.orig)
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := make([]int, n)
|
||||||
|
for i := range idx {
|
||||||
|
idx[i] = i
|
||||||
|
}
|
||||||
|
sort.SliceStable(idx, func(a, b int) bool { return cm.orig[idx[a]] < cm.orig[idx[b]] })
|
||||||
|
var os, hs []float64
|
||||||
|
for _, i := range idx {
|
||||||
|
o, h := cm.orig[i], cm.hint[i]
|
||||||
|
if len(os) > 0 && o == os[len(os)-1] {
|
||||||
|
// Same original edge from two hints: average the targets and keep one.
|
||||||
|
hs[len(hs)-1] = (hs[len(hs)-1] + h) / 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
os = append(os, o)
|
||||||
|
hs = append(hs, h)
|
||||||
|
}
|
||||||
|
cm.orig, cm.hint = os, hs
|
||||||
|
}
|
||||||
|
|
||||||
|
// remap maps a device coordinate through the control map: below the first
|
||||||
|
// control point or above the last it shifts by that endpoint's delta; between
|
||||||
|
// two it interpolates linearly. An empty map is the identity.
|
||||||
|
func (cm *controlMap) remap(v float64) float64 {
|
||||||
|
n := len(cm.orig)
|
||||||
|
switch {
|
||||||
|
case n == 0:
|
||||||
|
return v
|
||||||
|
case v <= cm.orig[0]:
|
||||||
|
return v + (cm.hint[0] - cm.orig[0])
|
||||||
|
case v >= cm.orig[n-1]:
|
||||||
|
return v + (cm.hint[n-1] - cm.orig[n-1])
|
||||||
|
}
|
||||||
|
i := sort.Search(n, func(k int) bool { return cm.orig[k] >= v })
|
||||||
|
a, b := cm.orig[i-1], cm.orig[i]
|
||||||
|
ha, hb := cm.hint[i-1], cm.hint[i]
|
||||||
|
t := (v - a) / (b - a)
|
||||||
|
return ha + t*(hb-ha)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintPoint holds one outline point through grid-fitting: its original scaled
|
||||||
|
// (device) position (ox, oy), its hinted position (hx, hy) and its on-curve flag.
|
||||||
|
type hintPoint struct {
|
||||||
|
ox, oy, hx, hy float64
|
||||||
|
on bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffGridFit grid-fits contours (in font units) using the recorded stems and
|
||||||
|
// Private-DICT hint parameters, returning the hinted contours in font units.
|
||||||
|
// scale is font-units-to-pixels and ppem is pixels/em (the BlueScale rule);
|
||||||
|
// darken enables stem darkening (weight compensation). It is a no-op returning
|
||||||
|
// equivalent geometry when there are no stems and no blue zones.
|
||||||
|
func cffGridFit(contours []contour, gh *cffGlyphHints, scale, ppem float64, darken bool) []contour {
|
||||||
|
priv := gh.priv
|
||||||
|
if priv == nil {
|
||||||
|
priv = &cffPrivate{blueScale: defaultBlueScale, blueShift: defaultBlueShift, blueFuzz: defaultBlueFuzz}
|
||||||
|
}
|
||||||
|
suppress := ppem*priv.blueScale < 1
|
||||||
|
blues := buildBlueZones(priv, scale)
|
||||||
|
|
||||||
|
// Grid-fit every stem once (its hinted position is independent of subpath),
|
||||||
|
// then equalise counter-group counters and optionally darken the stems.
|
||||||
|
hs := make([]hintedStem, len(gh.stems))
|
||||||
|
for i, s := range gh.stems {
|
||||||
|
hs[i] = hintStem(s, scale, priv, blues, suppress)
|
||||||
|
}
|
||||||
|
applyCounterControl(hs, gh.stems, gh.cntrGroups)
|
||||||
|
if darken {
|
||||||
|
darkenStems(hs, stemDarkenPixels(ppem))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap every point through the control map built from the stems active at
|
||||||
|
// that point (per-point so a mid-subpath hintmask change hints correctly). The
|
||||||
|
// maps for a given active-hintmask selection are built once and cached.
|
||||||
|
cache := map[int]axisMaps{}
|
||||||
|
getMaps := func(sel int) axisMaps {
|
||||||
|
if m, ok := cache[sel]; ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
var active []bool
|
||||||
|
if sel >= 0 {
|
||||||
|
active = gh.hintMasks[sel].active
|
||||||
|
}
|
||||||
|
x, y := buildMaps(hs, gh.stems, active, blues, scale)
|
||||||
|
m := axisMaps{x: x, y: y}
|
||||||
|
cache[sel] = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
for _, c := range contours {
|
||||||
|
total += len(c)
|
||||||
|
}
|
||||||
|
pts := make([]hintPoint, 0, total)
|
||||||
|
for _, c := range contours {
|
||||||
|
for _, p := range c {
|
||||||
|
ox, oy := p.x*scale, p.y*scale
|
||||||
|
m := getMaps(gh.maskSelAt(len(pts)))
|
||||||
|
pts = append(pts, hintPoint{ox: ox, oy: oy, hx: m.x.remap(ox), hy: m.y.remap(oy), on: p.on})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep flex spans smooth: re-derive each flex region's interior points by
|
||||||
|
// interpolating between its grid-fitted entry (start-1) and exit (end) anchors.
|
||||||
|
for _, fr := range gh.flexRanges {
|
||||||
|
if fr.start-1 < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
interpolateFlex(pts, fr.start-1, fr.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]contour, len(contours))
|
||||||
|
idx := 0
|
||||||
|
for ci, c := range contours {
|
||||||
|
nc := make(contour, len(c))
|
||||||
|
for j := range c {
|
||||||
|
p := pts[idx]
|
||||||
|
nc[j] = outlinePoint{x: p.hx / scale, y: p.hy / scale, on: p.on}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
out[ci] = nc
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// axisMaps pairs the x- and y-axis control maps for one active-hintmask
|
||||||
|
// selection.
|
||||||
|
type axisMaps struct {
|
||||||
|
x, y controlMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCounterControl equalises the counters of every recorded cntrmask group:
|
||||||
|
// within each group the same-orientation stems (independently for horizontal and
|
||||||
|
// vertical) are chained at an even, whole-pixel gap so features like the three
|
||||||
|
// verticals of 'm' keep uniform spacing after grid-fitting.
|
||||||
|
func applyCounterControl(hs []hintedStem, stems []cffStemHint, groups [][]int) {
|
||||||
|
for _, g := range groups {
|
||||||
|
equalizeCounters(hs, stems, g, true)
|
||||||
|
equalizeCounters(hs, stems, g, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// equalizeCounters repositions the horizontal (or vertical) stems of one counter
|
||||||
|
// group so the gaps between them are all the group's rounded average gap, keeping
|
||||||
|
// each stem's grid-fitted width and anchoring on the first (lowest) stem.
|
||||||
|
func equalizeCounters(hs []hintedStem, stems []cffStemHint, group []int, horizontal bool) {
|
||||||
|
var idx []int
|
||||||
|
for _, i := range group {
|
||||||
|
if stems[i].horizontal == horizontal {
|
||||||
|
idx = append(idx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(idx) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(idx, func(a, b int) bool { return hs[idx[a]].oLo < hs[idx[b]].oLo })
|
||||||
|
totalGap := 0.0
|
||||||
|
for k := 1; k < len(idx); k++ {
|
||||||
|
totalGap += hs[idx[k]].oLo - hs[idx[k-1]].oHi
|
||||||
|
}
|
||||||
|
gap := math.Round(totalGap / float64(len(idx)-1))
|
||||||
|
if gap < 1 {
|
||||||
|
gap = 1
|
||||||
|
}
|
||||||
|
for k := 1; k < len(idx); k++ {
|
||||||
|
w := hs[idx[k]].hHi - hs[idx[k]].hLo
|
||||||
|
lo := hs[idx[k-1]].hHi + gap
|
||||||
|
hs[idx[k]].hLo = lo
|
||||||
|
hs[idx[k]].hHi = lo + w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// darkenStems embiggens every stem by d device pixels per edge (its lower edge
|
||||||
|
// moves down and its upper edge up), the weight compensation that improves stem
|
||||||
|
// contrast on the anti-aliased rasteriser at small sizes.
|
||||||
|
func darkenStems(hs []hintedStem, d float64) {
|
||||||
|
for i := range hs {
|
||||||
|
hs[i].hLo -= d
|
||||||
|
hs[i].hHi += d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stemDarkenPixels returns the per-edge stem emboldening in device pixels at the
|
||||||
|
// given ppem: strongest at small sizes and fading linearly to none at and above
|
||||||
|
// darkenMaxPPEM, mirroring FreeType's default stem darkening (a contrast boost
|
||||||
|
// for small text that is invisible once glyphs are large enough to hint cleanly).
|
||||||
|
func stemDarkenPixels(ppem float64) float64 {
|
||||||
|
if ppem <= 0 || ppem >= darkenMaxPPEM {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return darkenMaxPixels * (1 - ppem/darkenMaxPPEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// darkenMaxPPEM is the ppem at (and above) which stem darkening stops, and
|
||||||
|
// darkenMaxPixels is its per-edge strength (in device pixels) at ppem→0.
|
||||||
|
const (
|
||||||
|
darkenMaxPPEM = 48
|
||||||
|
darkenMaxPixels = 0.15
|
||||||
|
)
|
||||||
|
|
||||||
|
// interpolateFlex re-derives the hinted positions of a flex region's interior
|
||||||
|
// points (before+1 .. end-1) by IUP-style interpolation between the grid-fitted
|
||||||
|
// anchors at indices before and end, so the shallow flex curve keeps its smooth
|
||||||
|
// shape rather than each interior point snapping to a stem edge.
|
||||||
|
func interpolateFlex(pts []hintPoint, before, end int) {
|
||||||
|
for k := before + 1; k < end; k++ {
|
||||||
|
pts[k].hx = interp1D(pts[before].ox, pts[end].ox, pts[before].hx, pts[end].hx, pts[k].ox)
|
||||||
|
pts[k].hy = interp1D(pts[before].oy, pts[end].oy, pts[before].hy, pts[end].hy, pts[k].oy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// interp1D maps o from the original interval [oA, oB] to the hinted interval,
|
||||||
|
// carrying the endpoint deltas: when the endpoints share an original coordinate
|
||||||
|
// (a degenerate interval) the point takes the anchor A's delta.
|
||||||
|
func interp1D(oA, oB, hA, hB, o float64) float64 {
|
||||||
|
dA := hA - oA
|
||||||
|
if oB == oA {
|
||||||
|
return o + dA
|
||||||
|
}
|
||||||
|
t := (o - oA) / (oB - oA)
|
||||||
|
return o + dA + t*((hB-oB)-dA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMaps assembles the x- and y-axis control maps for one subpath: each
|
||||||
|
// active vertical stem contributes its two edges to the x map and each active
|
||||||
|
// horizontal stem to the y map, and every blue zone contributes a flat-edge
|
||||||
|
// anchor to the y map (blue zones are always active). A nil active mask means
|
||||||
|
// every stem is active.
|
||||||
|
func buildMaps(hs []hintedStem, stems []cffStemHint, active []bool, blues []blueZone, scale float64) (x, y controlMap) {
|
||||||
|
for i, st := range hs {
|
||||||
|
if active != nil && !active[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stems[i].horizontal {
|
||||||
|
y.add(st.oLo, st.hLo)
|
||||||
|
y.add(st.oHi, st.hHi)
|
||||||
|
} else {
|
||||||
|
x.add(st.oLo, st.hLo)
|
||||||
|
x.add(st.oHi, st.hHi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, z := range blues {
|
||||||
|
f := z.flat * scale
|
||||||
|
y.add(f, math.Round(f))
|
||||||
|
}
|
||||||
|
x.finalize()
|
||||||
|
y.finalize()
|
||||||
|
return x, y
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (c) 2026, the go-opentype authors
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// cffStandardStrings are the 391 names every CFF font shares. A string id
|
||||||
|
// below this many is one of these; anything higher is an index into the
|
||||||
|
// font's own String INDEX. The list is fixed by the Compact Font Format
|
||||||
|
// specification and is the only way to learn what a glyph in such a font is
|
||||||
|
// called — which is what a PDF needs, because it addresses a simple font by
|
||||||
|
// name rather than by number.
|
||||||
|
//
|
||||||
|
// The first ninety-five of them are the printable ASCII characters in order,
|
||||||
|
// which is what makes the table checkable against the Standard Encoding
|
||||||
|
// rather than merely transcribed: see TestTheStandardStringsAgreeWithTheStandardEncoding.
|
||||||
|
var cffStandardStrings = [...]string{
|
||||||
|
".notdef", "space", "exclam", "quotedbl", "numbersign", "dollar", "percent",
|
||||||
|
"ampersand", "quoteright", "parenleft", "parenright", "asterisk", "plus",
|
||||||
|
"comma", "hyphen", "period", "slash", "zero", "one", "two", "three", "four",
|
||||||
|
"five", "six", "seven", "eight", "nine", "colon", "semicolon", "less",
|
||||||
|
"equal", "greater", "question", "at", "A", "B", "C", "D", "E", "F", "G",
|
||||||
|
"H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V",
|
||||||
|
"W", "X", "Y", "Z", "bracketleft", "backslash", "bracketright",
|
||||||
|
"asciicircum", "underscore", "quoteleft", "a", "b", "c", "d", "e", "f", "g",
|
||||||
|
"h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
|
||||||
|
"w", "x", "y", "z", "braceleft", "bar", "braceright", "asciitilde",
|
||||||
|
"exclamdown", "cent", "sterling", "fraction", "yen", "florin", "section",
|
||||||
|
"currency", "quotesingle", "quotedblleft", "guillemotleft", "guilsinglleft",
|
||||||
|
"guilsinglright", "fi", "fl", "endash", "dagger", "daggerdbl",
|
||||||
|
"periodcentered", "paragraph", "bullet", "quotesinglbase", "quotedblbase",
|
||||||
|
"quotedblright", "guillemotright", "ellipsis", "perthousand", "questiondown",
|
||||||
|
"grave", "acute", "circumflex", "tilde", "macron", "breve", "dotaccent",
|
||||||
|
"dieresis", "ring", "cedilla", "hungarumlaut", "ogonek", "caron", "emdash",
|
||||||
|
"AE", "ordfeminine", "Lslash", "Oslash", "OE", "ordmasculine", "ae",
|
||||||
|
"dotlessi", "lslash", "oslash", "oe", "germandbls", "onesuperior",
|
||||||
|
"logicalnot", "mu", "trademark", "Eth", "onehalf", "plusminus", "Thorn",
|
||||||
|
"onequarter", "divide", "brokenbar", "degree", "thorn", "threequarters",
|
||||||
|
"twosuperior", "registered", "minus", "eth", "multiply", "threesuperior",
|
||||||
|
"copyright", "Aacute", "Acircumflex", "Adieresis", "Agrave", "Aring",
|
||||||
|
"Atilde", "Ccedilla", "Eacute", "Ecircumflex", "Edieresis", "Egrave",
|
||||||
|
"Iacute", "Icircumflex", "Idieresis", "Igrave", "Ntilde", "Oacute",
|
||||||
|
"Ocircumflex", "Odieresis", "Ograve", "Otilde", "Scaron", "Uacute",
|
||||||
|
"Ucircumflex", "Udieresis", "Ugrave", "Yacute", "Ydieresis", "Zcaron",
|
||||||
|
"aacute", "acircumflex", "adieresis", "agrave", "aring", "atilde",
|
||||||
|
"ccedilla", "eacute", "ecircumflex", "edieresis", "egrave", "iacute",
|
||||||
|
"icircumflex", "idieresis", "igrave", "ntilde", "oacute", "ocircumflex",
|
||||||
|
"odieresis", "ograve", "otilde", "scaron", "uacute", "ucircumflex",
|
||||||
|
"udieresis", "ugrave", "yacute", "ydieresis", "zcaron", "exclamsmall",
|
||||||
|
"Hungarumlautsmall", "dollaroldstyle", "dollarsuperior", "ampersandsmall",
|
||||||
|
"Acutesmall", "parenleftsuperior", "parenrightsuperior", "twodotenleader",
|
||||||
|
"onedotenleader", "zerooldstyle", "oneoldstyle", "twooldstyle",
|
||||||
|
"threeoldstyle", "fouroldstyle", "fiveoldstyle", "sixoldstyle",
|
||||||
|
"sevenoldstyle", "eightoldstyle", "nineoldstyle", "commasuperior",
|
||||||
|
"threequartersemdash", "periodsuperior", "questionsmall", "asuperior",
|
||||||
|
"bsuperior", "centsuperior", "dsuperior", "esuperior", "isuperior",
|
||||||
|
"lsuperior", "msuperior", "nsuperior", "osuperior", "rsuperior", "ssuperior",
|
||||||
|
"tsuperior", "ff", "ffi", "ffl", "parenleftinferior", "parenrightinferior",
|
||||||
|
"Circumflexsmall", "hyphensuperior", "Gravesmall", "Asmall", "Bsmall",
|
||||||
|
"Csmall", "Dsmall", "Esmall", "Fsmall", "Gsmall", "Hsmall", "Ismall",
|
||||||
|
"Jsmall", "Ksmall", "Lsmall", "Msmall", "Nsmall", "Osmall", "Psmall",
|
||||||
|
"Qsmall", "Rsmall", "Ssmall", "Tsmall", "Usmall", "Vsmall", "Wsmall",
|
||||||
|
"Xsmall", "Ysmall", "Zsmall", "colonmonetary", "onefitted", "rupiah",
|
||||||
|
"Tildesmall", "exclamdownsmall", "centoldstyle", "Lslashsmall",
|
||||||
|
"Scaronsmall", "Zcaronsmall", "Dieresissmall", "Brevesmall", "Caronsmall",
|
||||||
|
"Dotaccentsmall", "Macronsmall", "figuredash", "hypheninferior",
|
||||||
|
"Ogoneksmall", "Ringsmall", "Cedillasmall", "questiondownsmall",
|
||||||
|
"oneeighth", "threeeighths", "fiveeighths", "seveneighths", "onethird",
|
||||||
|
"twothirds", "zerosuperior", "foursuperior", "fivesuperior", "sixsuperior",
|
||||||
|
"sevensuperior", "eightsuperior", "ninesuperior", "zeroinferior",
|
||||||
|
"oneinferior", "twoinferior", "threeinferior", "fourinferior",
|
||||||
|
"fiveinferior", "sixinferior", "seveninferior", "eightinferior",
|
||||||
|
"nineinferior", "centinferior", "dollarinferior", "periodinferior",
|
||||||
|
"commainferior", "Agravesmall", "Aacutesmall", "Acircumflexsmall",
|
||||||
|
"Atildesmall", "Adieresissmall", "Aringsmall", "AEsmall", "Ccedillasmall",
|
||||||
|
"Egravesmall", "Eacutesmall", "Ecircumflexsmall", "Edieresissmall",
|
||||||
|
"Igravesmall", "Iacutesmall", "Icircumflexsmall", "Idieresissmall",
|
||||||
|
"Ethsmall", "Ntildesmall", "Ogravesmall", "Oacutesmall", "Ocircumflexsmall",
|
||||||
|
"Otildesmall", "Odieresissmall", "OEsmall", "Oslashsmall", "Ugravesmall",
|
||||||
|
"Uacutesmall", "Ucircumflexsmall", "Udieresissmall", "Yacutesmall",
|
||||||
|
"Thornsmall", "Ydieresissmall", "001.000", "001.001", "001.002", "001.003",
|
||||||
|
"Black", "Bold", "Book", "Light", "Medium", "Regular", "Roman", "Semibold",
|
||||||
|
}
|
||||||
|
|
||||||
|
// nStdStrings is how many names every CFF font shares.
|
||||||
|
const nStdStrings = len(cffStandardStrings)
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// A font's cmap table carries several subtables, each written for one
|
||||||
|
// platform and encoding, and Font.GlyphIndex answers through whichever of
|
||||||
|
// them can represent the most codepoints. That is the right answer for a font
|
||||||
|
// addressed by character, and the wrong one for a font addressed some other
|
||||||
|
// way: a PDF says how its fonts are addressed, and a symbolic TrueType font
|
||||||
|
// embedded in one is addressed through its own Microsoft Symbol or Macintosh
|
||||||
|
// Roman subtable by raw byte, not through its Unicode subtable by codepoint.
|
||||||
|
//
|
||||||
|
// Only the document can make that choice, so the subtables are listed here
|
||||||
|
// rather than chosen here.
|
||||||
|
|
||||||
|
// NumCharacterMaps returns how many cmap subtables the font carries in a form
|
||||||
|
// this package decodes. It is zero for a font with no character map at all.
|
||||||
|
//
|
||||||
|
// Format 14 (Unicode Variation Sequences) is not one of them: it maps a pair
|
||||||
|
// rather than a code, and is reached through Font.GlyphIndexVariation.
|
||||||
|
func (f *Font) NumCharacterMaps() int { return len(f.cmaps) }
|
||||||
|
|
||||||
|
// CharacterMap reports which platform and encoding the i'th subtable was
|
||||||
|
// written for, and which format it is written in. ok is false when i is not
|
||||||
|
// the index of a subtable.
|
||||||
|
//
|
||||||
|
// The pairs worth recognising are (3,1) and (3,10), Microsoft Unicode, and
|
||||||
|
// platform 0, Unicode; (3,0), Microsoft Symbol, whose codes are the font's
|
||||||
|
// own and conventionally live at 0xF000 + code; and (1,0), Macintosh Roman.
|
||||||
|
func (f *Font) CharacterMap(i int) (platform, encoding, format uint16, ok bool) {
|
||||||
|
if i < 0 || i >= len(f.cmaps) {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
c := f.cmaps[i]
|
||||||
|
return c.platform, c.encoding, c.format, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphIndexInMap maps a code to a glyph through one named subtable rather
|
||||||
|
// than through the one the font would choose. ok is false when i is not the
|
||||||
|
// index of a subtable, or when that subtable does not map the code (including
|
||||||
|
// a mapping to the .notdef glyph).
|
||||||
|
//
|
||||||
|
// The code is whatever the subtable is indexed by: a Unicode codepoint for a
|
||||||
|
// Unicode subtable, and a raw byte of the font's own encoding for a Microsoft
|
||||||
|
// Symbol or Macintosh Roman one.
|
||||||
|
func (f *Font) GlyphIndexInMap(i int, code rune) (GlyphIndex, bool) {
|
||||||
|
if i < 0 || i >= len(f.cmaps) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.cmaps[i].lookup.lookup(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuneOfGlyphInMap inverts one subtable: which code reaches this glyph. ok is
|
||||||
|
// false when i is not the index of a subtable, or when no code in it reaches
|
||||||
|
// that glyph.
|
||||||
|
//
|
||||||
|
// A subtable maps many codes onto one glyph — a space and a no-break space
|
||||||
|
// commonly share one — and the lowest of them is the one reported. Inverting a
|
||||||
|
// Unicode subtable is how the character a glyph stands for is recovered when
|
||||||
|
// the document says nothing about it and the program names nothing: the font
|
||||||
|
// itself said which codepoint that glyph is for.
|
||||||
|
//
|
||||||
|
// The inverse is built once per subtable, on the first call, and kept.
|
||||||
|
func (f *Font) RuneOfGlyphInMap(i int, gid GlyphIndex) (rune, bool) {
|
||||||
|
if i < 0 || i >= len(f.cmaps) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if f.cmaps[i].inverse == nil {
|
||||||
|
f.cmaps[i].inverse = f.cmaps[i].lookup.reverse(maxReverseCodes)
|
||||||
|
}
|
||||||
|
r, ok := f.cmaps[i].inverse[gid]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
+933
@@ -0,0 +1,933 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// cmapLookup maps a rune to a glyph index. ok is false when the rune is not
|
||||||
|
// mapped by the selected subtable (including a mapping to the .notdef glyph).
|
||||||
|
//
|
||||||
|
// reverse answers the other question: which code reaches a given glyph. A
|
||||||
|
// subtable is a many-to-one map — a space and a no-break space commonly share
|
||||||
|
// one glyph — so the inverse keeps the lowest code that reaches each glyph,
|
||||||
|
// and is built once for the whole subtable rather than searched per glyph.
|
||||||
|
//
|
||||||
|
// budget bounds how many codes the walk may visit. Most formats describe no
|
||||||
|
// more codes than their own bytes can hold and ignore it; formats 4, 8 and 12
|
||||||
|
// do not — one group of a format-12 subtable can claim the whole Unicode
|
||||||
|
// space, and a damaged one can claim more — so those three spend it.
|
||||||
|
type cmapLookup interface {
|
||||||
|
lookup(r rune) (GlyphIndex, bool)
|
||||||
|
reverse(budget int) map[GlyphIndex]rune
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxReverseCodes is the budget Font.RuneOfGlyphInMap gives a walk. A subtable
|
||||||
|
// that describes more than a million codes is describing far more than any
|
||||||
|
// inverse of it will be asked about, and the bound keeps a malformed font from
|
||||||
|
// costing more than a well-formed one.
|
||||||
|
const maxReverseCodes = 1 << 20
|
||||||
|
|
||||||
|
// reverseRange records the codes [first, first+count) as reaching the glyphs
|
||||||
|
// starting at gid, one glyph per code, into inv. It keeps the lowest code for
|
||||||
|
// each glyph and reports how many codes it consumed of the budget left.
|
||||||
|
func reverseRange(inv map[GlyphIndex]rune, first, count, gid uint32, budget int) int {
|
||||||
|
if uint32(budget) < count {
|
||||||
|
count = uint32(budget)
|
||||||
|
}
|
||||||
|
for i := uint32(0); i < count; i++ {
|
||||||
|
g := GlyphIndex(gid + i)
|
||||||
|
if g == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[g]; !seen {
|
||||||
|
inv[g] = rune(first + i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmapSubtable is one decoded cmap subtable together with the platform and
|
||||||
|
// encoding it was written for. Which of them to address a font through is the
|
||||||
|
// caller's decision and cannot be made here: a font embedded in a PDF is
|
||||||
|
// addressed the way the document says, and only the document knows.
|
||||||
|
type cmapSubtable struct {
|
||||||
|
platform uint16
|
||||||
|
encoding uint16
|
||||||
|
format uint16
|
||||||
|
lookup cmapLookup
|
||||||
|
inverse map[GlyphIndex]rune // built on first use by Font.RuneOfGlyphInMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap selects and decodes a Unicode cmap subtable. It understands
|
||||||
|
// formats 0, 2, 4, 6, 8, 10, 12 and 13 for the base rune->glyph mapping,
|
||||||
|
// preferring whichever can represent the most codepoints:
|
||||||
|
// 13 = 12 > 10 > 8 > 4 > 6 > 2 > 0.
|
||||||
|
// Format 14 (Unicode Variation Sequences) is a separate, always-parsed side
|
||||||
|
// table exposed through Font.GlyphIndexVariation; it never competes for f.cmap
|
||||||
|
// and its presence does not change ordinary GlyphIndex behaviour.
|
||||||
|
func (f *Font) parseCmap(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // version
|
||||||
|
numTables := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: cmap header: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var best cmapLookup
|
||||||
|
bestScore := 0
|
||||||
|
for i := 0; i < numTables; i++ {
|
||||||
|
platform := r.u16()
|
||||||
|
encoding := r.u16()
|
||||||
|
offset := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: cmap record: %w", r.err)
|
||||||
|
}
|
||||||
|
if offset+2 > len(b) {
|
||||||
|
return fmt.Errorf("opentype: cmap subtable offset: %w", errTruncated)
|
||||||
|
}
|
||||||
|
sub := b[offset:]
|
||||||
|
format := be16(sub)
|
||||||
|
var (
|
||||||
|
lk cmapLookup
|
||||||
|
score int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch format {
|
||||||
|
case 0:
|
||||||
|
lk, err = parseCmap0(sub)
|
||||||
|
score = 1
|
||||||
|
case 2:
|
||||||
|
lk, err = parseCmap2(sub)
|
||||||
|
score = 2
|
||||||
|
case 6:
|
||||||
|
lk, err = parseCmap6(sub)
|
||||||
|
score = 3
|
||||||
|
case 4:
|
||||||
|
lk, err = parseCmap4(sub)
|
||||||
|
score = 4
|
||||||
|
case 8:
|
||||||
|
lk, err = parseCmap8(sub)
|
||||||
|
score = 5
|
||||||
|
case 10:
|
||||||
|
lk, err = parseCmap10(sub)
|
||||||
|
score = 6
|
||||||
|
case 12:
|
||||||
|
lk, err = parseCmap12(sub)
|
||||||
|
score = 7
|
||||||
|
case 13:
|
||||||
|
lk, err = parseCmap13(sub)
|
||||||
|
score = 7
|
||||||
|
case 14:
|
||||||
|
vs, verr := parseCmap14(sub)
|
||||||
|
if verr != nil {
|
||||||
|
return verr
|
||||||
|
}
|
||||||
|
f.cmapVS = vs
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.cmaps = append(f.cmaps, cmapSubtable{
|
||||||
|
platform: platform,
|
||||||
|
encoding: encoding,
|
||||||
|
format: format,
|
||||||
|
lookup: lk,
|
||||||
|
})
|
||||||
|
if score > bestScore {
|
||||||
|
best = lk
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return fmt.Errorf("opentype: no supported cmap subtable")
|
||||||
|
}
|
||||||
|
f.cmap = best
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap4 is a decoded format-4 (segmented, BMP) cmap subtable.
|
||||||
|
type cmap4 struct {
|
||||||
|
segCount int
|
||||||
|
endCode []uint16
|
||||||
|
startCode []uint16
|
||||||
|
idDelta []int16
|
||||||
|
idRangeOffset []uint16
|
||||||
|
glyphIDArray []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap4 decodes a format-4 subtable from sub (which starts at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap4(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(6) // format, length, language
|
||||||
|
segX2 := int(r.u16())
|
||||||
|
segCount := segX2 / 2
|
||||||
|
r.skip(6) // searchRange, entrySelector, rangeShift
|
||||||
|
c := &cmap4{
|
||||||
|
segCount: segCount,
|
||||||
|
endCode: make([]uint16, segCount),
|
||||||
|
startCode: make([]uint16, segCount),
|
||||||
|
idDelta: make([]int16, segCount),
|
||||||
|
idRangeOffset: make([]uint16, segCount),
|
||||||
|
}
|
||||||
|
for i := 0; i < segCount; i++ {
|
||||||
|
c.endCode[i] = r.u16()
|
||||||
|
}
|
||||||
|
r.skip(2) // reservedPad
|
||||||
|
for i := 0; i < segCount; i++ {
|
||||||
|
c.startCode[i] = r.u16()
|
||||||
|
}
|
||||||
|
for i := 0; i < segCount; i++ {
|
||||||
|
c.idDelta[i] = r.i16()
|
||||||
|
}
|
||||||
|
for i := 0; i < segCount; i++ {
|
||||||
|
c.idRangeOffset[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 4: %w", r.err)
|
||||||
|
}
|
||||||
|
// The remaining bytes are the glyphIdArray (uint16 each).
|
||||||
|
rest := (len(sub) - r.pos) / 2
|
||||||
|
c.glyphIDArray = make([]uint16, rest)
|
||||||
|
for i := 0; i < rest; i++ {
|
||||||
|
c.glyphIDArray[i] = r.u16()
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup implements the format-4 segment search with idDelta/idRangeOffset.
|
||||||
|
func (c *cmap4) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
if uint32(r) > 0xFFFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
cc := uint16(r)
|
||||||
|
for i := 0; i < c.segCount; i++ {
|
||||||
|
if c.endCode[i] < cc {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.startCode[i] > cc {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var g uint16
|
||||||
|
if c.idRangeOffset[i] == 0 {
|
||||||
|
g = cc + uint16(c.idDelta[i])
|
||||||
|
} else {
|
||||||
|
idx := int(c.idRangeOffset[i]/2) + int(cc-c.startCode[i]) - (c.segCount - i)
|
||||||
|
if idx < 0 || idx >= len(c.glyphIDArray) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g = c.glyphIDArray[idx]
|
||||||
|
if g != 0 {
|
||||||
|
g += uint16(c.idDelta[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(g), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse walks the segments in code order, resolving each code the same way
|
||||||
|
// lookup does, and keeps the lowest code that reaches each glyph.
|
||||||
|
func (c *cmap4) reverse(budget int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for i := 0; i < c.segCount; i++ {
|
||||||
|
if c.startCode[i] > c.endCode[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for cc := uint32(c.startCode[i]); cc <= uint32(c.endCode[i]); cc++ {
|
||||||
|
if budget == 0 {
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
budget--
|
||||||
|
g, ok := c.lookup(rune(cc))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[g]; !seen {
|
||||||
|
inv[g] = rune(cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap12group is one sequential-map group of a format-12 subtable.
|
||||||
|
type cmap12group struct {
|
||||||
|
start uint32
|
||||||
|
end uint32
|
||||||
|
startGID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap12 is a decoded format-12 (segmented coverage, full Unicode) subtable.
|
||||||
|
type cmap12 struct {
|
||||||
|
groups []cmap12group
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap12 decodes a format-12 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap12(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(12) // format, reserved, length, language
|
||||||
|
numGroups := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 12 header: %w", r.err)
|
||||||
|
}
|
||||||
|
c := &cmap12{groups: make([]cmap12group, numGroups)}
|
||||||
|
for i := 0; i < numGroups; i++ {
|
||||||
|
c.groups[i].start = r.u32()
|
||||||
|
c.groups[i].end = r.u32()
|
||||||
|
c.groups[i].startGID = r.u32()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 12 groups: %w", r.err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup implements the format-12 binary search over sorted groups.
|
||||||
|
func (c *cmap12) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
cc := uint32(r)
|
||||||
|
lo, hi := 0, len(c.groups)
|
||||||
|
for lo < hi {
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
g := c.groups[mid]
|
||||||
|
if cc < g.start {
|
||||||
|
hi = mid
|
||||||
|
} else if cc > g.end {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
return GlyphIndex(g.startGID + (cc - g.start)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse expands each group: a group advances one glyph per code point, so
|
||||||
|
// the codes it covers reach a contiguous run of glyphs.
|
||||||
|
func (c *cmap12) reverse(budget int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for _, g := range c.groups {
|
||||||
|
if g.end < g.start {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
budget -= reverseRange(inv, g.start, g.end-g.start+1, g.startGID, budget)
|
||||||
|
if budget == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap13group is one constant-map group of a format-13 subtable: every code
|
||||||
|
// point in [start, end] maps to the single glyph glyphID.
|
||||||
|
type cmap13group struct {
|
||||||
|
start uint32
|
||||||
|
end uint32
|
||||||
|
glyphID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap13 is a decoded format-13 (many-to-one range mappings) subtable. Its
|
||||||
|
// on-disk layout is identical to format 12, but the third group field is read
|
||||||
|
// with different semantics: format 12's startGlyphID advances one glyph per
|
||||||
|
// code point across a group, whereas format 13's glyphID is a constant so the
|
||||||
|
// whole range collapses onto a single glyph. This is what LastResort and other
|
||||||
|
// fallback fonts use to point every code point in a block at one "missing
|
||||||
|
// glyph" placeholder.
|
||||||
|
type cmap13 struct {
|
||||||
|
groups []cmap13group
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap13 decodes a format-13 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap13(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(12) // format, reserved, length, language
|
||||||
|
numGroups := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 13 header: %w", r.err)
|
||||||
|
}
|
||||||
|
c := &cmap13{groups: make([]cmap13group, numGroups)}
|
||||||
|
for i := 0; i < numGroups; i++ {
|
||||||
|
c.groups[i].start = r.u32()
|
||||||
|
c.groups[i].end = r.u32()
|
||||||
|
c.groups[i].glyphID = r.u32()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 13 groups: %w", r.err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup implements the format-13 binary search over sorted groups, returning
|
||||||
|
// the group's constant glyph for any code point it covers.
|
||||||
|
func (c *cmap13) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
cc := uint32(r)
|
||||||
|
lo, hi := 0, len(c.groups)
|
||||||
|
for lo < hi {
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
g := c.groups[mid]
|
||||||
|
if cc < g.start {
|
||||||
|
hi = mid
|
||||||
|
} else if cc > g.end {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
return GlyphIndex(g.glyphID), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse keeps one code per group: every code point a format-13 group covers
|
||||||
|
// reaches the same glyph, so only the lowest of them is worth recording, and a
|
||||||
|
// group covering a whole Unicode block costs one entry rather than a million.
|
||||||
|
// The budget goes unspent: a group costs one entry however many codes it
|
||||||
|
// covers, so the walk is only as long as the subtable itself.
|
||||||
|
func (c *cmap13) reverse(int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for _, g := range c.groups {
|
||||||
|
if g.end < g.start || g.glyphID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gid := GlyphIndex(g.glyphID)
|
||||||
|
if _, seen := inv[gid]; !seen {
|
||||||
|
inv[gid] = rune(g.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap8Group is one sequential-map group of a format-8 subtable; identical in
|
||||||
|
// shape and semantics to a format-12 group (a range advancing one glyph per
|
||||||
|
// code point from startGID).
|
||||||
|
type cmap8Group struct {
|
||||||
|
start uint32
|
||||||
|
end uint32
|
||||||
|
startGID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap8 is a decoded format-8 (mixed 16/32-bit coverage) subtable. is32 is
|
||||||
|
// the on-disk 65536-bit bitmap (8192 bytes) flagging which 16-bit lead code
|
||||||
|
// units begin a 32-bit (surrogate-pair) character rather than standing alone
|
||||||
|
// as a BMP character; it is retained for spec fidelity but, like other
|
||||||
|
// implementations, is not consulted by lookup, since lookup already receives
|
||||||
|
// a fully decoded rune rather than raw UTF-16 code units — the groups alone
|
||||||
|
// are sufficient to resolve it, exactly as in format 12.
|
||||||
|
type cmap8 struct {
|
||||||
|
is32 [8192]byte
|
||||||
|
groups []cmap8Group
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap8 decodes a format-8 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap8(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(12) // format, reserved, length, language
|
||||||
|
var c cmap8
|
||||||
|
for i := range c.is32 {
|
||||||
|
c.is32[i] = r.u8()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 8 is32: %w", r.err)
|
||||||
|
}
|
||||||
|
numGroups := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 8 header: %w", r.err)
|
||||||
|
}
|
||||||
|
c.groups = make([]cmap8Group, numGroups)
|
||||||
|
for i := range c.groups {
|
||||||
|
c.groups[i].start = r.u32()
|
||||||
|
c.groups[i].end = r.u32()
|
||||||
|
c.groups[i].startGID = r.u32()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 8 groups: %w", r.err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup implements the format-8 binary search over sorted groups, the same
|
||||||
|
// algorithm as format 12's lookup (the is32 bitmap plays no role once the
|
||||||
|
// input is already a decoded rune).
|
||||||
|
func (c *cmap8) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
cc := uint32(r)
|
||||||
|
lo, hi := 0, len(c.groups)
|
||||||
|
for lo < hi {
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
g := c.groups[mid]
|
||||||
|
if cc < g.start {
|
||||||
|
hi = mid
|
||||||
|
} else if cc > g.end {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
return GlyphIndex(g.startGID + (cc - g.start)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse expands each group, exactly as format 12's does: the two formats
|
||||||
|
// describe their groups the same way.
|
||||||
|
func (c *cmap8) reverse(budget int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for _, g := range c.groups {
|
||||||
|
if g.end < g.start {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
budget -= reverseRange(inv, g.start, g.end-g.start+1, g.startGID, budget)
|
||||||
|
if budget == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap0 is a decoded format-0 (byte encoding table) subtable: a flat 256-byte
|
||||||
|
// glyph-id array covering U+0000-U+00FF.
|
||||||
|
type cmap0 struct {
|
||||||
|
glyphIDArray [256]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap0 decodes a format-0 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap0(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(6) // format, length, language
|
||||||
|
var c cmap0
|
||||||
|
for i := range c.glyphIDArray {
|
||||||
|
c.glyphIDArray[i] = r.u8()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 0: %w", r.err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the direct array entry for r, or false when r is outside
|
||||||
|
// U+0000-U+00FF or maps to the .notdef glyph.
|
||||||
|
func (c *cmap0) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
if uint32(r) > 0xFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g := c.glyphIDArray[r]
|
||||||
|
if g == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(g), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse walks the 256-entry array, keeping the lowest code per glyph.
|
||||||
|
// The budget goes unspent: the array is 256 entries.
|
||||||
|
func (c *cmap0) reverse(int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for code, g := range c.glyphIDArray {
|
||||||
|
if g == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[GlyphIndex(g)]; !seen {
|
||||||
|
inv[GlyphIndex(g)] = rune(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap6 is a decoded format-6 (trimmed table mapping) subtable: a
|
||||||
|
// contiguous, zero-based glyph-id array starting at firstCode.
|
||||||
|
type cmap6 struct {
|
||||||
|
firstCode uint16
|
||||||
|
glyphIDArray []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap6 decodes a format-6 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap6(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(6) // format, length, language
|
||||||
|
firstCode := r.u16()
|
||||||
|
entryCount := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 6 header: %w", r.err)
|
||||||
|
}
|
||||||
|
c := &cmap6{firstCode: firstCode, glyphIDArray: make([]uint16, entryCount)}
|
||||||
|
for i := range c.glyphIDArray {
|
||||||
|
c.glyphIDArray[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 6: %w", r.err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the trimmed-table entry for r, or false when r falls
|
||||||
|
// outside [firstCode, firstCode+len(glyphIDArray)) or maps to .notdef.
|
||||||
|
func (c *cmap6) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
if uint32(r) < uint32(c.firstCode) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idx := uint32(r) - uint32(c.firstCode)
|
||||||
|
if idx >= uint32(len(c.glyphIDArray)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g := c.glyphIDArray[idx]
|
||||||
|
if g == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(g), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse walks the trimmed array, keeping the lowest code per glyph.
|
||||||
|
// The budget goes unspent: the array is no longer than the subtable said.
|
||||||
|
func (c *cmap6) reverse(int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for i, g := range c.glyphIDArray {
|
||||||
|
if g == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[GlyphIndex(g)]; !seen {
|
||||||
|
inv[GlyphIndex(g)] = rune(uint32(c.firstCode) + uint32(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap10 is a decoded format-10 (trimmed array) subtable: the 32-bit
|
||||||
|
// analogue of format 6 — a contiguous, zero-based glyph-id array starting at
|
||||||
|
// startCharCode, wide enough to cover astral code points.
|
||||||
|
type cmap10 struct {
|
||||||
|
startCharCode uint32
|
||||||
|
glyphIDArray []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap10 decodes a format-10 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap10(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(12) // format, reserved, length, language
|
||||||
|
startCharCode := r.u32()
|
||||||
|
numChars := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 10 header: %w", r.err)
|
||||||
|
}
|
||||||
|
c := &cmap10{startCharCode: startCharCode, glyphIDArray: make([]uint16, numChars)}
|
||||||
|
for i := range c.glyphIDArray {
|
||||||
|
c.glyphIDArray[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 10: %w", r.err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the trimmed-table entry for r, or false when r falls
|
||||||
|
// outside [startCharCode, startCharCode+len(glyphIDArray)) or maps to
|
||||||
|
// .notdef.
|
||||||
|
func (c *cmap10) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
if uint32(r) < c.startCharCode {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idx := uint32(r) - c.startCharCode
|
||||||
|
if idx >= uint32(len(c.glyphIDArray)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g := c.glyphIDArray[idx]
|
||||||
|
if g == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(g), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse walks the trimmed array, keeping the lowest code per glyph.
|
||||||
|
// The budget goes unspent: the array is no longer than the subtable said.
|
||||||
|
func (c *cmap10) reverse(int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for i, g := range c.glyphIDArray {
|
||||||
|
if g == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[GlyphIndex(g)]; !seen {
|
||||||
|
inv[GlyphIndex(g)] = rune(c.startCharCode + uint32(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap2SubHeader is one decoded subHeader record of a format-2 subtable.
|
||||||
|
// fieldOffset is the byte offset of this subHeader's idRangeOffset field
|
||||||
|
// relative to the start of glyphIndexArray, precomputed so lookup can turn
|
||||||
|
// (fieldOffset + idRangeOffset + 2*index) into a glyphIndexArray index the
|
||||||
|
// same way format 4 turns its own idRangeOffset into an array index.
|
||||||
|
type cmap2SubHeader struct {
|
||||||
|
firstCode uint16
|
||||||
|
entryCount uint16
|
||||||
|
idDelta int16
|
||||||
|
idRangeOffset uint16
|
||||||
|
fieldOffset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap2 is a decoded format-2 (high-byte mapping through table) subtable,
|
||||||
|
// the legacy encoding for mixed single/double-byte CJK charsets. The code
|
||||||
|
// passed to lookup is treated as a raw 1- or 2-byte legacy code (its low 16
|
||||||
|
// bits), exactly like the other cmap formats treat their input as a raw
|
||||||
|
// code rather than interpreting charset semantics.
|
||||||
|
type cmap2 struct {
|
||||||
|
subHeaderKeys [256]uint16 // per high-byte value, subHeaders' byte offset (index*8)
|
||||||
|
subHeaders []cmap2SubHeader
|
||||||
|
glyphIndexArray []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap2 decodes a format-2 subtable from sub (starting at the format
|
||||||
|
// field). The subtable has no explicit subHeader count; it is derived from
|
||||||
|
// the highest offset referenced by subHeaderKeys, following the same
|
||||||
|
// convention used by other OpenType/TrueType cmap format-2 parsers.
|
||||||
|
func parseCmap2(sub []byte) (cmapLookup, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(6) // format, length, language
|
||||||
|
var c cmap2
|
||||||
|
for i := range c.subHeaderKeys {
|
||||||
|
c.subHeaderKeys[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 2 keys: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxKey uint16
|
||||||
|
for _, k := range c.subHeaderKeys {
|
||||||
|
if k > maxKey {
|
||||||
|
maxKey = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numSubHeaders := int(maxKey)/8 + 1
|
||||||
|
glyphIndexArrayStart := r.pos + numSubHeaders*8
|
||||||
|
|
||||||
|
c.subHeaders = make([]cmap2SubHeader, numSubHeaders)
|
||||||
|
for i := range c.subHeaders {
|
||||||
|
fieldPos := r.pos + 6 // firstCode(2)+entryCount(2)+idDelta(2) precede idRangeOffset
|
||||||
|
c.subHeaders[i] = cmap2SubHeader{
|
||||||
|
firstCode: r.u16(),
|
||||||
|
entryCount: r.u16(),
|
||||||
|
idDelta: r.i16(),
|
||||||
|
idRangeOffset: r.u16(),
|
||||||
|
fieldOffset: fieldPos - glyphIndexArrayStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 2 subheaders: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remaining bytes are the glyphIndexArray (uint16 each); rest is sized
|
||||||
|
// to whatever fits, so this run of reads never overruns.
|
||||||
|
rest := (len(sub) - r.pos) / 2
|
||||||
|
c.glyphIndexArray = make([]uint16, rest)
|
||||||
|
for i := range c.glyphIndexArray {
|
||||||
|
c.glyphIndexArray[i] = r.u16()
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup implements the format-2 high-byte dispatch: the high byte of r
|
||||||
|
// selects a subHeader via subHeaderKeys (a value of 0 selects subHeader 0,
|
||||||
|
// which also serves single-byte codes, since a single-byte code has an
|
||||||
|
// implicit high byte of 0), then the low byte is checked against that
|
||||||
|
// subHeader's [firstCode, firstCode+entryCount) range and translated through
|
||||||
|
// glyphIndexArray and idDelta, mirroring format 4's idRangeOffset scheme.
|
||||||
|
func (c *cmap2) lookup(r rune) (GlyphIndex, bool) {
|
||||||
|
if uint32(r) > 0xFFFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
code := uint32(r)
|
||||||
|
hi := (code >> 8) & 0xFF
|
||||||
|
lo := code & 0xFF
|
||||||
|
|
||||||
|
sh := c.subHeaders[int(c.subHeaderKeys[hi])/8]
|
||||||
|
rel := lo - uint32(sh.firstCode)
|
||||||
|
if rel >= uint32(sh.entryCount) || sh.idRangeOffset == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
gidx := (sh.fieldOffset + int(sh.idRangeOffset) + 2*int(rel)) / 2
|
||||||
|
if gidx < 0 || gidx >= len(c.glyphIndexArray) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g := c.glyphIndexArray[gidx]
|
||||||
|
if g == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return GlyphIndex(uint16(int32(g) + int32(sh.idDelta))), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse walks every code the high-byte dispatch can reach, resolving each
|
||||||
|
// the same way lookup does. A single-byte code has an implicit high byte of
|
||||||
|
// zero, so the walk covers the whole 16-bit space rather than only the codes
|
||||||
|
// some subHeader claims.
|
||||||
|
// The budget goes unspent: the walk is the 16-bit code space, once.
|
||||||
|
func (c *cmap2) reverse(int) map[GlyphIndex]rune {
|
||||||
|
inv := make(map[GlyphIndex]rune)
|
||||||
|
for cc := uint32(0); cc <= 0xFFFF; cc++ {
|
||||||
|
g, ok := c.lookup(rune(cc))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := inv[g]; !seen {
|
||||||
|
inv[g] = rune(cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap14DefaultRange is one entry of a format-14 default-UVS table: base
|
||||||
|
// runes in [start, start+count) pair with this variation selector to
|
||||||
|
// resolve through the font's ordinary cmap rather than an explicit glyph.
|
||||||
|
type cmap14DefaultRange struct {
|
||||||
|
start uint32
|
||||||
|
count uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap14NonDefault is one entry of a format-14 non-default-UVS table: base
|
||||||
|
// rune unicode paired with this variation selector resolves to glyph
|
||||||
|
// directly, bypassing the ordinary cmap.
|
||||||
|
type cmap14NonDefault struct {
|
||||||
|
unicode uint32
|
||||||
|
glyph GlyphIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap14Record is one variation-selector record of a format-14 subtable.
|
||||||
|
type cmap14Record struct {
|
||||||
|
varSelector uint32
|
||||||
|
defaultRanges []cmap14DefaultRange
|
||||||
|
nonDefault []cmap14NonDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmap14 is a decoded format-14 (Unicode Variation Sequences) subtable. It is
|
||||||
|
// never installed as Font.cmap: it is a side table consulted only through
|
||||||
|
// GlyphIndexVariation.
|
||||||
|
type cmap14 struct {
|
||||||
|
records []cmap14Record
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmap14 decodes a format-14 subtable from sub (starting at the format
|
||||||
|
// field).
|
||||||
|
func parseCmap14(sub []byte) (*cmap14, error) {
|
||||||
|
r := reader{b: sub}
|
||||||
|
r.skip(6) // format, length
|
||||||
|
numRecords := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 header: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawRecord struct {
|
||||||
|
varSelector uint32
|
||||||
|
defaultOff, nonDefaultOff uint32
|
||||||
|
}
|
||||||
|
raws := make([]rawRecord, numRecords)
|
||||||
|
for i := range raws {
|
||||||
|
raws[i] = rawRecord{
|
||||||
|
varSelector: r.u24(),
|
||||||
|
defaultOff: r.u32(),
|
||||||
|
nonDefaultOff: r.u32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 records: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &cmap14{records: make([]cmap14Record, numRecords)}
|
||||||
|
for i, raw := range raws {
|
||||||
|
c.records[i].varSelector = raw.varSelector
|
||||||
|
if raw.defaultOff != 0 {
|
||||||
|
ranges, err := parseUVSDefaultTable(sub, int(raw.defaultOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.records[i].defaultRanges = ranges
|
||||||
|
}
|
||||||
|
if raw.nonDefaultOff != 0 {
|
||||||
|
maps, err := parseUVSNonDefaultTable(sub, int(raw.nonDefaultOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.records[i].nonDefault = maps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUVSDefaultTable decodes a Default UVS table at byte offset off within
|
||||||
|
// sub (the format-14 subtable, offsets are subtable-relative). The offset
|
||||||
|
// itself is only checked against sub's length here; a short read of the
|
||||||
|
// table's own fields is reported separately by the reader below.
|
||||||
|
func parseUVSDefaultTable(sub []byte, off int) ([]cmap14DefaultRange, error) {
|
||||||
|
if off > len(sub) {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 default UVS offset: %w", errTruncated)
|
||||||
|
}
|
||||||
|
r := reader{b: sub[off:]}
|
||||||
|
numRanges := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 default UVS header: %w", r.err)
|
||||||
|
}
|
||||||
|
ranges := make([]cmap14DefaultRange, numRanges)
|
||||||
|
for i := range ranges {
|
||||||
|
start := r.u24()
|
||||||
|
additionalCount := r.u8()
|
||||||
|
ranges[i] = cmap14DefaultRange{start: start, count: uint32(additionalCount) + 1}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 default UVS ranges: %w", r.err)
|
||||||
|
}
|
||||||
|
return ranges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUVSNonDefaultTable decodes a Non-Default UVS table at byte offset off
|
||||||
|
// within sub (the format-14 subtable, offsets are subtable-relative). The
|
||||||
|
// offset itself is only checked against sub's length here; a short read of
|
||||||
|
// the table's own fields is reported separately by the reader below.
|
||||||
|
func parseUVSNonDefaultTable(sub []byte, off int) ([]cmap14NonDefault, error) {
|
||||||
|
if off > len(sub) {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 non-default UVS offset: %w", errTruncated)
|
||||||
|
}
|
||||||
|
r := reader{b: sub[off:]}
|
||||||
|
numMappings := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 non-default UVS header: %w", r.err)
|
||||||
|
}
|
||||||
|
maps := make([]cmap14NonDefault, numMappings)
|
||||||
|
for i := range maps {
|
||||||
|
maps[i] = cmap14NonDefault{unicode: r.u24(), glyph: GlyphIndex(r.u16())}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: cmap format 14 non-default UVS mappings: %w", r.err)
|
||||||
|
}
|
||||||
|
return maps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupVariation resolves (r, vs) against c's records. A non-default
|
||||||
|
// mapping (an explicit glyph) takes precedence over a default-range match
|
||||||
|
// (which falls back to f's ordinary cmap); a vs with no record, or an r
|
||||||
|
// matched by neither its non-default mappings nor its default ranges,
|
||||||
|
// reports not-ok.
|
||||||
|
func (c *cmap14) lookupVariation(f *Font, r, vs rune) (GlyphIndex, bool) {
|
||||||
|
target := uint32(vs)
|
||||||
|
for _, rec := range c.records {
|
||||||
|
if rec.varSelector != target {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cc := uint32(r)
|
||||||
|
for _, nd := range rec.nonDefault {
|
||||||
|
if nd.unicode == cc {
|
||||||
|
return nd.glyph, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dr := range rec.defaultRanges {
|
||||||
|
if cc >= dr.start && cc-dr.start < dr.count {
|
||||||
|
return f.GlyphIndex(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// This file surfaces the font-level descriptor scalars a PDF FontDescriptor (and
|
||||||
|
// general typographic use) needs, without a consumer having to re-parse the sfnt
|
||||||
|
// container itself. The values are decoded from head (bounding box, macStyle),
|
||||||
|
// OS/2 (weight/width class, cap/x height, family class, selection flags) and post
|
||||||
|
// (italic angle, fixed-pitch) at Parse time; the accessors below expose them.
|
||||||
|
//
|
||||||
|
// Every scalar has a defined value even when its source table is absent: the
|
||||||
|
// head-derived ones are always available, while the OS/2 and post ones fall back
|
||||||
|
// to a documented default (0, or the ascender for a missing cap height) so a
|
||||||
|
// caller never has to special-case a partial font.
|
||||||
|
|
||||||
|
// parseDescriptor decodes the optional OS/2 and post tables into the descriptor
|
||||||
|
// scalars. Both are optional: a font without them keeps the zero defaults, which
|
||||||
|
// the accessors translate into sensible fallbacks. A too-short table is treated
|
||||||
|
// as absent rather than an error, matching the tolerant handling a descriptor
|
||||||
|
// consumer expects (the required metrics all come from head/hhea, already parsed).
|
||||||
|
func (f *Font) parseDescriptor(tables map[string][]byte) {
|
||||||
|
if b, ok := tables["OS/2"]; ok && len(b) >= 78 {
|
||||||
|
f.os2Present = true
|
||||||
|
f.os2Version = int(be16(b))
|
||||||
|
f.weightClass = int(be16(b[4:]))
|
||||||
|
f.widthClass = int(be16(b[6:]))
|
||||||
|
f.sFamilyClass = int(sbe16(b[30:]))
|
||||||
|
f.fsSelection = be16(b[62:])
|
||||||
|
// sxHeight and sCapHeight were added in OS/2 version 2; older tables (and
|
||||||
|
// truncated version-2 ones) simply do not carry them.
|
||||||
|
if f.os2Version >= 2 && len(b) >= 90 {
|
||||||
|
f.sxHeight = int(sbe16(b[86:]))
|
||||||
|
f.sCapHeight = int(sbe16(b[88:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["post"]; ok && len(b) >= 16 {
|
||||||
|
f.postPresent = true
|
||||||
|
f.italicAngle = float64(int32(be32(b[4:]))) / 65536.0
|
||||||
|
f.isFixedPitch = be32(b[12:]) != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitsPerEm returns the font's design units per em (from head), the scale in
|
||||||
|
// which every other font-unit metric this package reports is expressed. It is
|
||||||
|
// never zero (Parse rejects a font whose head declares zero).
|
||||||
|
func (f *Font) UnitsPerEm() int { return f.unitsPerEm }
|
||||||
|
|
||||||
|
// Ascent returns the typographic ascender in font units (hhea.ascender): the
|
||||||
|
// height above the baseline. It is the same value Face.Metrics scales to pixels.
|
||||||
|
func (f *Font) Ascent() int { return f.ascender }
|
||||||
|
|
||||||
|
// Descent returns the typographic descender in font units (hhea.descender). It
|
||||||
|
// is conventionally negative (below the baseline), as stored in hhea.
|
||||||
|
func (f *Font) Descent() int { return f.descender }
|
||||||
|
|
||||||
|
// LineGap returns the typographic line gap in font units (hhea.lineGap): the
|
||||||
|
// recommended extra spacing between lines beyond ascent minus descent.
|
||||||
|
func (f *Font) LineGap() int { return f.lineGap }
|
||||||
|
|
||||||
|
// FontBBox returns the font bounding box in font units (head.xMin/yMin/xMax/yMax):
|
||||||
|
// the union of every glyph's bounds. A PDF FontDescriptor's /FontBBox is this box
|
||||||
|
// scaled to glyph space.
|
||||||
|
func (f *Font) FontBBox() (xMin, yMin, xMax, yMax int) {
|
||||||
|
return f.xMin, f.yMin, f.xMax, f.yMax
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapHeight returns the cap height in font units (OS/2.sCapHeight). It is zero
|
||||||
|
// when the font has no OS/2 table or an OS/2 version below 2, which do not carry
|
||||||
|
// it; a PDF consumer that needs a non-zero value substitutes the ascender.
|
||||||
|
func (f *Font) CapHeight() int { return f.sCapHeight }
|
||||||
|
|
||||||
|
// XHeight returns the x height in font units (OS/2.sxHeight). It is zero when the
|
||||||
|
// font has no OS/2 table or an OS/2 version below 2 (which do not carry it).
|
||||||
|
func (f *Font) XHeight() int { return f.sxHeight }
|
||||||
|
|
||||||
|
// ItalicAngle returns the italic (slant) angle in counter-clockwise degrees from
|
||||||
|
// vertical (post.italicAngle): zero for an upright font, negative for the usual
|
||||||
|
// forward slant. It is zero when the font has no post table.
|
||||||
|
func (f *Font) ItalicAngle() float64 { return f.italicAngle }
|
||||||
|
|
||||||
|
// WeightClass returns the OS/2 usWeightClass (100 thin .. 400 normal .. 900 black),
|
||||||
|
// or 0 when the font carries no OS/2 table.
|
||||||
|
func (f *Font) WeightClass() int { return f.weightClass }
|
||||||
|
|
||||||
|
// WidthClass returns the OS/2 usWidthClass (1 ultra-condensed .. 5 normal ..
|
||||||
|
// 9 ultra-expanded), or 0 when the font carries no OS/2 table.
|
||||||
|
func (f *Font) WidthClass() int { return f.widthClass }
|
||||||
|
|
||||||
|
// IsFixedPitch reports whether the font is monospaced (post.isFixedPitch).
|
||||||
|
func (f *Font) IsFixedPitch() bool { return f.isFixedPitch }
|
||||||
|
|
||||||
|
// IsItalic reports whether the font is italic or oblique, from any of the three
|
||||||
|
// places a font may record it: a non-zero post italic angle, the head macStyle
|
||||||
|
// italic bit, or the OS/2 fsSelection italic bit.
|
||||||
|
func (f *Font) IsItalic() bool {
|
||||||
|
return f.italicAngle != 0 || f.macStyle&0x02 != 0 || f.fsSelection&0x01 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSerif reports whether the font is a serif design, read from the high byte of
|
||||||
|
// OS/2.sFamilyClass (the IBM font-class id): classes 1-7 are serif families,
|
||||||
|
// class 8 is sans-serif, and the rest (script, symbolic, ...) are neither. It is
|
||||||
|
// false when the font has no OS/2 table.
|
||||||
|
func (f *Font) IsSerif() bool {
|
||||||
|
class := f.sFamilyClass >> 8
|
||||||
|
return class >= 1 && class <= 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSymbolic reports whether the font is a symbol font (OS/2 sFamilyClass 12).
|
||||||
|
// It drives the PDF Symbolic/Nonsymbolic flag: symbol fonts must be flagged
|
||||||
|
// Symbolic, every other font Nonsymbolic.
|
||||||
|
func (f *Font) isSymbolic() bool { return f.sFamilyClass>>8 == 12 }
|
||||||
|
|
||||||
|
// PDF FontDescriptor flag bits (PDF 32000-1:2008, Table 123).
|
||||||
|
const (
|
||||||
|
pdfFlagFixedPitch = 1 << 0
|
||||||
|
pdfFlagSerif = 1 << 1
|
||||||
|
pdfFlagSymbolic = 1 << 2
|
||||||
|
pdfFlagNonsymbolic = 1 << 5
|
||||||
|
pdfFlagItalic = 1 << 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags returns the PDF FontDescriptor /Flags value for the font: a bit set
|
||||||
|
// combining FixedPitch, Serif, Symbolic-or-Nonsymbolic and Italic, derived from
|
||||||
|
// the post, OS/2 and head tables. A caller embeds it verbatim as /Flags. Exactly
|
||||||
|
// one of the Symbolic and Nonsymbolic bits is always set.
|
||||||
|
func (f *Font) Flags() int {
|
||||||
|
flags := 0
|
||||||
|
if f.isFixedPitch {
|
||||||
|
flags |= pdfFlagFixedPitch
|
||||||
|
}
|
||||||
|
if f.IsSerif() {
|
||||||
|
flags |= pdfFlagSerif
|
||||||
|
}
|
||||||
|
if f.isSymbolic() {
|
||||||
|
flags |= pdfFlagSymbolic
|
||||||
|
} else {
|
||||||
|
flags |= pdfFlagNonsymbolic
|
||||||
|
}
|
||||||
|
if f.IsItalic() {
|
||||||
|
flags |= pdfFlagItalic
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// StemV returns an estimate, in font units, of the dominant vertical stem width
|
||||||
|
// a PDF FontDescriptor requires as /StemV. OpenType fonts do not store StemV, so
|
||||||
|
// it is approximated from the OS/2 weight class with the linear relation
|
||||||
|
// 50 + (weightClass-100)*11/100 (about 83 at the normal weight 400, ~116 at bold
|
||||||
|
// 700), clamped to a floor of 50. A font without an OS/2 table is treated as the
|
||||||
|
// normal weight. Callers that can do better should read WeightClass directly.
|
||||||
|
func (f *Font) StemV() int {
|
||||||
|
w := f.weightClass
|
||||||
|
if w == 0 {
|
||||||
|
w = 400
|
||||||
|
}
|
||||||
|
v := 50 + (w-100)*11/100
|
||||||
|
if v < 50 {
|
||||||
|
v = 50
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
// Package opentype is a pure-Go, CGO=0, standard-library-only parser and
|
||||||
|
// anti-aliased rasteriser for TrueType/OpenType fonts.
|
||||||
|
//
|
||||||
|
// It is a functional replacement for the narrow slice of
|
||||||
|
// golang.org/x/image/font/opentype needed by a glyph-blitting UI: parse a
|
||||||
|
// font blob, build a Face at a pixel size, then obtain per-rune advances and
|
||||||
|
// 8-bit alpha coverage masks.
|
||||||
|
//
|
||||||
|
// # Scope
|
||||||
|
//
|
||||||
|
// Phase 1 covers TrueType 'glyf' outlines: the sfnt container, the head,
|
||||||
|
// maxp, hhea, hmtx, cmap (formats 4 and 12), loca and glyf tables, simple and
|
||||||
|
// composite glyphs, implied on-curve points, and a non-zero-winding
|
||||||
|
// supersampling rasteriser. OpenType/CFF ('OTTO') outlines, GPOS/GSUB
|
||||||
|
// shaping, kerning and hinting are not implemented; see the README for the
|
||||||
|
// full support matrix.
|
||||||
|
//
|
||||||
|
// The OpenType MATH table (math-typesetting metrics: constants, per-glyph
|
||||||
|
// italic correction and kerning, and stretchy-glyph size variants and
|
||||||
|
// assemblies) is decoded and exposed pixel-scaled through a Face; see
|
||||||
|
// [Font.HasMath] and math.go. Math layout itself (box building, the TeX math
|
||||||
|
// rules) is a higher-level engine that consumes these values.
|
||||||
|
//
|
||||||
|
// # Usage
|
||||||
|
//
|
||||||
|
// f, err := opentype.Parse(ttf)
|
||||||
|
// if err != nil { /* handle */ }
|
||||||
|
// face := f.NewFace(16)
|
||||||
|
// adv := face.Measure("Hello")
|
||||||
|
// bounds, mask, maskp, advance, ok := face.GlyphMask('H', penX, baselineY)
|
||||||
|
//
|
||||||
|
// A Font is immutable after Parse and safe for concurrent use; a Face caches
|
||||||
|
// rasterised glyphs and is not safe for concurrent use.
|
||||||
|
//
|
||||||
|
// # Embedding and subsetting
|
||||||
|
//
|
||||||
|
// For a consumer that embeds a font (a PDF or EPUB writer), the package exposes
|
||||||
|
// the primitives such an embedder otherwise re-implements by re-parsing the sfnt:
|
||||||
|
// the PDF FontDescriptor scalars ([Font.UnitsPerEm], [Font.FontBBox],
|
||||||
|
// [Font.CapHeight], [Font.XHeight], [Font.ItalicAngle], [Font.StemV],
|
||||||
|
// [Font.Flags], ...); raw table access ([Font.TableTags], [Font.Table]);
|
||||||
|
// by-glyph-index advances that honour the current variation
|
||||||
|
// ([Face.AdvanceIndex], [Face.AdvanceIndexUnits], [Face.VerticalAdvanceIndex]);
|
||||||
|
// TrueType glyph subsetting ([Font.SubsetTrueType]) and CFF charstring subsetting
|
||||||
|
// ([Font.SubsetCFF]); and baking a variable font at a chosen axis position into a
|
||||||
|
// static instance ([Font.Instance], [Font.InstanceBytes]).
|
||||||
|
package opentype
|
||||||
+358
@@ -0,0 +1,358 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics holds a Face's vertical metrics in whole pixels.
|
||||||
|
type Metrics struct {
|
||||||
|
Ascent int // baseline-to-top distance (a positive height above the baseline)
|
||||||
|
Descent int // baseline-to-bottom distance (a positive depth below the baseline)
|
||||||
|
Height int // line height: ascent + descent + line gap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face renders a Font at a fixed pixel size. It caches rasterised glyphs and
|
||||||
|
// is not safe for concurrent use; build one Face per goroutine if needed.
|
||||||
|
type Face struct {
|
||||||
|
font *Font
|
||||||
|
sizePx int
|
||||||
|
scale float64
|
||||||
|
cache map[GlyphIndex]cachedGlyph
|
||||||
|
|
||||||
|
// varCoords, when non-nil, instances a variable font's glyf outlines at the
|
||||||
|
// given user-space axis coordinates before rasterising (see SetVariation).
|
||||||
|
// varNorm caches those coordinates normalized (NormalizeCoords) so the
|
||||||
|
// metric-variation tables (HVAR/VVAR/MVAR) can be evaluated per call.
|
||||||
|
varCoords map[string]float64
|
||||||
|
varNorm []int16
|
||||||
|
|
||||||
|
// hinting enables the TrueType instruction interpreter for glyf glyphs that
|
||||||
|
// carry instructions (see SetHinting). interp is built lazily on first use
|
||||||
|
// and reused across glyphs; interpReady guards that one-time build.
|
||||||
|
hinting bool
|
||||||
|
interp *interp
|
||||||
|
interpReady bool
|
||||||
|
|
||||||
|
// stemDarken enables CFF/CFF2 stem darkening (weight compensation) when
|
||||||
|
// hinting is on: stems are embiggened by a small ppem-dependent amount to
|
||||||
|
// improve contrast on the anti-aliased rasteriser at small sizes (see
|
||||||
|
// SetStemDarkening). Off by default, so the default hinted output is
|
||||||
|
// byte-identical to the undarkened result.
|
||||||
|
stemDarken bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedGlyph is a rasterised glyph held in the Face cache. ok is false when
|
||||||
|
// the glyph could not be decoded (a corrupt outline), in which case it renders
|
||||||
|
// as nothing.
|
||||||
|
type cachedGlyph struct {
|
||||||
|
bounds image.Rectangle
|
||||||
|
mask *image.Alpha
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundInt rounds a float to the nearest integer (halves away from zero).
|
||||||
|
func roundInt(v float64) int { return int(math.Round(v)) }
|
||||||
|
|
||||||
|
// NewFace returns a Face that renders f at sizePx pixels per em. The scale
|
||||||
|
// factor is sizePx / unitsPerEm; sizePx should be positive.
|
||||||
|
func (f *Font) NewFace(sizePx int) *Face {
|
||||||
|
return &Face{
|
||||||
|
font: f,
|
||||||
|
sizePx: sizePx,
|
||||||
|
scale: float64(sizePx) / float64(f.unitsPerEm),
|
||||||
|
cache: map[GlyphIndex]cachedGlyph{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font returns the Font this Face renders. It lets a shaper reach the font's
|
||||||
|
// layout tables ([Font.GSUB], [Font.GPOS]) and per-glyph metrics
|
||||||
|
// ([Font.GlyphAdvance]) while keeping the Face for pixel sizing.
|
||||||
|
func (fc *Face) Font() *Font { return fc.font }
|
||||||
|
|
||||||
|
// Scale returns the face's font-unit-to-pixel scale factor (the render size in
|
||||||
|
// pixels divided by the font's unitsPerEm). A shaper multiplies a font-unit
|
||||||
|
// advance or offset by it to obtain whole pixels at the face's size.
|
||||||
|
func (fc *Face) Scale() float64 { return fc.scale }
|
||||||
|
|
||||||
|
// Metrics returns the face's vertical metrics in pixels. When a variation is
|
||||||
|
// set and the font carries an MVAR table, the global metric deltas for the
|
||||||
|
// ascender ("hasc"), descender ("hdsc") and line gap ("hlgp") are folded in.
|
||||||
|
func (fc *Face) Metrics() Metrics {
|
||||||
|
s := fc.scale
|
||||||
|
asc := float64(fc.font.ascender)
|
||||||
|
desc := float64(fc.font.descender)
|
||||||
|
gap := float64(fc.font.lineGap)
|
||||||
|
if fc.varCoords != nil && fc.font.mvar != nil {
|
||||||
|
asc += fc.font.mvar.delta("hasc", fc.varNorm)
|
||||||
|
desc += fc.font.mvar.delta("hdsc", fc.varNorm)
|
||||||
|
gap += fc.font.mvar.delta("hlgp", fc.varNorm)
|
||||||
|
}
|
||||||
|
return Metrics{
|
||||||
|
Ascent: roundInt(asc * s),
|
||||||
|
Descent: roundInt(-desc * s),
|
||||||
|
Height: roundInt((asc - desc + gap) * s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hAdvanceUnits returns glyph gid's horizontal advance in font units at the
|
||||||
|
// face's current variation: the base advance plus, when a variation is set, the
|
||||||
|
// HVAR delta (or the gvar phantom-point fallback). With no variation set the
|
||||||
|
// result is exactly the base advance.
|
||||||
|
func (fc *Face) hAdvanceUnits(gid GlyphIndex) float64 {
|
||||||
|
adv := float64(fc.font.GlyphAdvance(gid))
|
||||||
|
if fc.varCoords != nil {
|
||||||
|
adv += fc.font.horizontalAdvanceDelta(int(gid), fc.varNorm)
|
||||||
|
}
|
||||||
|
return adv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance returns the horizontal advance of r in pixels, or 0 if the rune is
|
||||||
|
// not mapped by the font's cmap. When a variation is set the HVAR delta (or the
|
||||||
|
// gvar phantom-point fallback) is included.
|
||||||
|
func (fc *Face) Advance(r rune) int {
|
||||||
|
gid, ok := fc.font.GlyphIndex(r)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return roundInt(fc.hAdvanceUnits(gid) * fc.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure returns the total advance width of s in pixels (the sum of each
|
||||||
|
// rune's Advance).
|
||||||
|
func (fc *Face) Measure(s string) int {
|
||||||
|
total := 0
|
||||||
|
for _, r := range s {
|
||||||
|
total += fc.Advance(r)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyph returns the cached rasterisation of glyph gid, rendering it on first
|
||||||
|
// use.
|
||||||
|
func (fc *Face) glyph(gid GlyphIndex) cachedGlyph {
|
||||||
|
if cg, ok := fc.cache[gid]; ok {
|
||||||
|
return cg
|
||||||
|
}
|
||||||
|
cg := fc.render(gid)
|
||||||
|
fc.cache[gid] = cg
|
||||||
|
return cg
|
||||||
|
}
|
||||||
|
|
||||||
|
// render decodes and rasterises glyph gid at the face's size.
|
||||||
|
func (fc *Face) render(gid GlyphIndex) cachedGlyph {
|
||||||
|
contours, err := fc.outline(gid)
|
||||||
|
if err != nil {
|
||||||
|
return cachedGlyph{ok: false}
|
||||||
|
}
|
||||||
|
polys := make([][]fpoint, 0, len(contours))
|
||||||
|
for _, c := range contours {
|
||||||
|
p := flattenContour(c, fc.scale)
|
||||||
|
if len(p) >= 3 {
|
||||||
|
polys = append(polys, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounds, mask := rasterize(polys)
|
||||||
|
return cachedGlyph{bounds: bounds, mask: mask, ok: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphMask rasterises r and positions it with (x, y) as the pen origin on the
|
||||||
|
// baseline. It returns the destination bounds, an *image.Alpha coverage mask,
|
||||||
|
// the offset into that mask corresponding to bounds.Min (always the origin),
|
||||||
|
// the advance width in pixels, and ok.
|
||||||
|
//
|
||||||
|
// ok is false when the rune is not mapped by the cmap or its glyph outline is
|
||||||
|
// corrupt; callers should render nothing in that case. A mapped-but-empty
|
||||||
|
// glyph (for example a space) returns ok true with a nil mask, an empty
|
||||||
|
// bounds, and its advance.
|
||||||
|
func (fc *Face) GlyphMask(r rune, x, y int) (bounds image.Rectangle, mask *image.Alpha, maskp image.Point, advance int, ok bool) {
|
||||||
|
gid, mapped := fc.font.GlyphIndex(r)
|
||||||
|
if !mapped {
|
||||||
|
return image.Rectangle{}, nil, image.Point{}, 0, false
|
||||||
|
}
|
||||||
|
cg := fc.glyph(gid)
|
||||||
|
if !cg.ok {
|
||||||
|
return image.Rectangle{}, nil, image.Point{}, 0, false
|
||||||
|
}
|
||||||
|
advance = roundInt(fc.hAdvanceUnits(gid) * fc.scale)
|
||||||
|
bounds = cg.bounds.Add(image.Point{X: x, Y: y})
|
||||||
|
return bounds, cg.mask, image.Point{}, advance, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphMaskIndex rasterises the glyph with index gid directly — bypassing the
|
||||||
|
// cmap — and positions it with (x, y) as the pen origin on the baseline. It is
|
||||||
|
// the by-glyph-index counterpart of [Face.GlyphMask]: a complex-text shaper
|
||||||
|
// (see github.com/go-opentype/shape) emits glyph indices after applying GSUB
|
||||||
|
// substitutions and GPOS positioning, so the runes have already been resolved
|
||||||
|
// to glyphs and a caller must render each glyph by its index rather than by a
|
||||||
|
// rune. Callers add the shaper's per-glyph pixel offset to (x, y) before the
|
||||||
|
// call and advance the pen by the shaper's advance afterwards.
|
||||||
|
//
|
||||||
|
// It returns the destination bounds, an *image.Alpha coverage mask, the offset
|
||||||
|
// into that mask corresponding to bounds.Min (always the origin), the glyph's
|
||||||
|
// own horizontal advance width in pixels, and ok.
|
||||||
|
//
|
||||||
|
// ok is false when gid is out of range or its outline is corrupt; callers
|
||||||
|
// should render nothing in that case. A valid-but-empty glyph (for example a
|
||||||
|
// space) returns ok true with a nil mask, an empty bounds, and its advance.
|
||||||
|
func (fc *Face) GlyphMaskIndex(gid GlyphIndex, x, y int) (bounds image.Rectangle, mask *image.Alpha, maskp image.Point, advance int, ok bool) {
|
||||||
|
cg := fc.glyph(gid)
|
||||||
|
if !cg.ok {
|
||||||
|
return image.Rectangle{}, nil, image.Point{}, 0, false
|
||||||
|
}
|
||||||
|
advance = roundInt(fc.hAdvanceUnits(gid) * fc.scale)
|
||||||
|
bounds = cg.bounds.Add(image.Point{X: x, Y: y})
|
||||||
|
return bounds, cg.mask, image.Point{}, advance, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// outline returns glyph gid's outline as contours in font units, selecting the
|
||||||
|
// outline source (CFF2 vs CFF vs glyf), applying variable-font instancing when a
|
||||||
|
// variation is set, and applying hinting when it is enabled.
|
||||||
|
//
|
||||||
|
// A CFF2 font is inherently variable: its outlines are instanced at the face's
|
||||||
|
// variation coordinates (the default master when none is set), converted to the
|
||||||
|
// normalized F2Dot14 form cff2.outline expects via NormalizeCoords. When hinting
|
||||||
|
// is enabled both CFF2 and CFF1 outlines are grid-fitted (see cffhint.go), so
|
||||||
|
// SetHinting affects all three outline flavours; with hinting off they are
|
||||||
|
// returned unhinted.
|
||||||
|
func (fc *Face) outline(gid GlyphIndex) ([]contour, error) {
|
||||||
|
if fc.font.t1 != nil {
|
||||||
|
// A Type 1 program carries hints of its own, in a form nothing here
|
||||||
|
// grid-fits; its outlines are drawn as they were written.
|
||||||
|
return fc.font.t1.outline(int(gid))
|
||||||
|
}
|
||||||
|
if fc.font.cff2 != nil {
|
||||||
|
contours, gh, err := fc.font.cff2.outlineHints(int(gid), fc.font.NormalizeCoords(fc.varCoords))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fc.hinting {
|
||||||
|
contours = cffGridFit(contours, gh, fc.scale, float64(fc.sizePx), fc.stemDarken)
|
||||||
|
}
|
||||||
|
return contours, nil
|
||||||
|
}
|
||||||
|
if fc.font.cff != nil {
|
||||||
|
contours, gh, err := fc.font.cff.outlineHints(int(gid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fc.hinting {
|
||||||
|
contours = cffGridFit(contours, gh, fc.scale, float64(fc.sizePx), fc.stemDarken)
|
||||||
|
}
|
||||||
|
return contours, nil
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
contours []contour
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if fc.varCoords != nil {
|
||||||
|
contours, err = fc.font.InstancePoints(int(gid), fc.varCoords)
|
||||||
|
} else {
|
||||||
|
contours, err = fc.font.glyphContours(gid)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fc.hinting {
|
||||||
|
contours = fc.hintedOutline(gid, contours)
|
||||||
|
}
|
||||||
|
return contours, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVariation instances the font at the user-space axis coordinates coords
|
||||||
|
// (keyed by axis tag, e.g. {"wght": 700}); axes absent from coords take their
|
||||||
|
// default. Subsequent GlyphMask calls reflect the instanced outlines, for both
|
||||||
|
// glyf (via gvar) and CFF2 (via its blend/vsindex operators) variable fonts.
|
||||||
|
// Pass nil to return to the default master. A non-variable font (or a CFF1 font,
|
||||||
|
// which has no variation) is unaffected. The glyph cache is invalidated so the
|
||||||
|
// change takes effect at once.
|
||||||
|
func (fc *Face) SetVariation(coords map[string]float64) {
|
||||||
|
fc.varCoords = coords
|
||||||
|
fc.varNorm = fc.font.NormalizeCoords(coords)
|
||||||
|
fc.cache = map[GlyphIndex]cachedGlyph{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHinting enables or disables the TrueType instruction interpreter. When
|
||||||
|
// enabled, a glyf glyph that carries instructions is grid-fitted at the face's
|
||||||
|
// pixel size before rasterising; glyphs without instructions, composite glyphs
|
||||||
|
// and CFF fonts are unaffected. Hinting is off by default (the default output is
|
||||||
|
// unhinted anti-aliased coverage). The glyph cache is invalidated.
|
||||||
|
func (fc *Face) SetHinting(on bool) {
|
||||||
|
fc.hinting = on
|
||||||
|
fc.cache = map[GlyphIndex]cachedGlyph{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStemDarkening enables or disables stem darkening (weight compensation) for
|
||||||
|
// CFF/CFF2 glyphs. When both hinting and darkening are on, every grid-fitted stem
|
||||||
|
// is embiggened by a small ppem-dependent amount (strongest at small sizes,
|
||||||
|
// fading to none at larger sizes) so stems keep enough contrast on the
|
||||||
|
// anti-aliased rasteriser at small text sizes, as FreeType's CFF engine does by
|
||||||
|
// default. It has no effect while hinting is off or on glyf (TrueType) outlines.
|
||||||
|
// Darkening is off by default, so the default hinted output is byte-identical to
|
||||||
|
// the undarkened result. The glyph cache is invalidated.
|
||||||
|
func (fc *Face) SetStemDarkening(on bool) {
|
||||||
|
fc.stemDarken = on
|
||||||
|
fc.cache = map[GlyphIndex]cachedGlyph{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintInterp lazily builds the interpreter for this face (running the font
|
||||||
|
// program and control-value program once) and reuses it across glyphs. It
|
||||||
|
// returns nil when the font has no interpreter or its programs fail to run, in
|
||||||
|
// which case the caller renders the glyph unhinted.
|
||||||
|
func (fc *Face) hintInterp() *interp {
|
||||||
|
if !fc.interpReady {
|
||||||
|
fc.interpReady = true
|
||||||
|
if it, err := newInterp(fc.font, fc.sizePx); err == nil {
|
||||||
|
fc.interp = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fc.interp
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintedOutline grid-fits glyph gid's contours through the TrueType interpreter
|
||||||
|
// and returns the hinted contours in font units. It falls back to the input
|
||||||
|
// contours when the glyph carries no instructions, the interpreter is
|
||||||
|
// unavailable, or the program fails. The whole outline is treated as one closed
|
||||||
|
// contour for IUP, matching the interpreter's model (see hint.go).
|
||||||
|
func (fc *Face) hintedOutline(gid GlyphIndex, contours []contour) []contour {
|
||||||
|
instr := fc.font.glyphInstructions(gid)
|
||||||
|
if len(instr) == 0 {
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
it := fc.hintInterp()
|
||||||
|
if it == nil {
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
// Scale to pixel space (what the interpreter expects), recording contour
|
||||||
|
// boundaries so the hinted points can be re-split into contours.
|
||||||
|
var pts []outlinePoint
|
||||||
|
ends := make([]int, 0, len(contours))
|
||||||
|
for _, c := range contours {
|
||||||
|
for _, p := range c {
|
||||||
|
pts = append(pts, outlinePoint{x: p.x * fc.scale, y: p.y * fc.scale, on: p.on})
|
||||||
|
}
|
||||||
|
ends = append(ends, len(pts)-1)
|
||||||
|
}
|
||||||
|
out, err := it.run(pts, instr)
|
||||||
|
if err != nil {
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
// Back to font units so the shared rasteriser (which re-applies scale)
|
||||||
|
// reproduces the hinted pixel positions.
|
||||||
|
result := make([]contour, len(contours))
|
||||||
|
start := 0
|
||||||
|
for i, e := range ends {
|
||||||
|
cc := make(contour, e-start+1)
|
||||||
|
for j := range cc {
|
||||||
|
p := out[start+j]
|
||||||
|
cc[j] = outlinePoint{x: p.x / fc.scale, y: p.y / fc.scale, on: p.on}
|
||||||
|
}
|
||||||
|
result[i] = cc
|
||||||
|
start = e + 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
+345
@@ -0,0 +1,345 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GlyphIndex is a glyph identifier within a font: an index into the font's
|
||||||
|
// glyph store, as produced by the cmap. Zero is the ".notdef" glyph.
|
||||||
|
type GlyphIndex uint16
|
||||||
|
|
||||||
|
// errTruncated is the sentinel a bounded reader records when a read runs past
|
||||||
|
// the end of its slice. Callers wrap it with table context.
|
||||||
|
var errTruncated = errors.New("unexpected end of data")
|
||||||
|
|
||||||
|
// Font is a parsed TrueType/OpenType font. It is immutable after Parse and
|
||||||
|
// safe for concurrent use by multiple goroutines. Build a Face from it with
|
||||||
|
// NewFace to obtain sized metrics and rasterised glyphs.
|
||||||
|
type Font struct {
|
||||||
|
unitsPerEm int
|
||||||
|
indexToLocFormat int
|
||||||
|
numGlyphs int
|
||||||
|
ascender int
|
||||||
|
descender int
|
||||||
|
lineGap int
|
||||||
|
numberOfHMetrics int
|
||||||
|
advances []int // advanceWidth per glyph, in font units
|
||||||
|
lsbs []int // left side bearing per glyph, in font units
|
||||||
|
loca []uint32
|
||||||
|
glyf []byte
|
||||||
|
cmap cmapLookup
|
||||||
|
cmaps []cmapSubtable // every decoded cmap subtable, with the platform and encoding it was written for
|
||||||
|
cmapVS *cmap14 // format-14 Unicode Variation Sequences subtable, if present
|
||||||
|
cff *cffTable // CFF/Type2 outlines for an OpenType ("OTTO") font, if present
|
||||||
|
t1 *type1Font // PostScript Type 1 outlines, for a program read by ParseType1
|
||||||
|
glyphNames map[string]GlyphIndex // what the font calls its glyphs, when it names them
|
||||||
|
cff2 *cff2Table // CFF2 (variable Compact Font Format) outlines, if present
|
||||||
|
fvar *fvarTable // optional: variation axes and named instances
|
||||||
|
avar *avarTable // optional: axis-value segment maps
|
||||||
|
gvar *gvarTable // optional: glyph variation (delta) data
|
||||||
|
hvar *hvarTable // optional: horizontal metrics variation (advances)
|
||||||
|
vvar *vvarTable // optional: vertical metrics variation (advances)
|
||||||
|
mvar *mvarTable // optional: global font-metric variation
|
||||||
|
|
||||||
|
// OpenType Layout tables, all optional. Absence is not an error.
|
||||||
|
gdef *gdefTable // glyph definitions (classes, mark sets) for lookup flags
|
||||||
|
gsub *gsub // glyph substitution (ligatures, single substitution)
|
||||||
|
gpos *gpos // glyph positioning (pair kerning)
|
||||||
|
kern *kern // legacy kern table (kerning fallback)
|
||||||
|
|
||||||
|
// OpenType MATH table (math-typesetting metrics), optional. Absence is not an
|
||||||
|
// error; it powers Font.HasMath and the Face math accessors (see math.go).
|
||||||
|
math *mathTable
|
||||||
|
|
||||||
|
// Vertical writing-mode metrics (vhea/vmtx/VORG), all optional. They enable
|
||||||
|
// top-to-bottom (CJK tategaki) layout; absence means vertical metrics are
|
||||||
|
// unavailable. See vertical.go.
|
||||||
|
vertAscender int // vhea vertTypoAscender, font units
|
||||||
|
vertDescender int // vhea vertTypoDescender, font units
|
||||||
|
vertLineGap int // vhea vertTypoLineGap, font units
|
||||||
|
numOfLongVerMetrics int // vhea count of full vmtx entries
|
||||||
|
vertAdvances []int // advanceHeight per glyph, font units (vmtx)
|
||||||
|
tsbs []int // top side bearing per glyph, font units (vmtx)
|
||||||
|
vorgDefault int // VORG defaultVertOriginY, font units
|
||||||
|
vorg map[GlyphIndex]int // VORG per-glyph vertical origin overrides
|
||||||
|
hasVhea bool
|
||||||
|
hasVmtx bool
|
||||||
|
hasVORG bool
|
||||||
|
|
||||||
|
// TrueType instruction (hinting) tables and limits. These are optional:
|
||||||
|
// a font without them simply cannot be hinted. See hint.go.
|
||||||
|
fpgm []byte // font program (function definitions)
|
||||||
|
prep []byte // control-value program
|
||||||
|
cvt []int16
|
||||||
|
maxStorage int
|
||||||
|
maxFunctionDefs int
|
||||||
|
maxStackElements int
|
||||||
|
maxTwilightPts int
|
||||||
|
|
||||||
|
// Descriptor scalars decoded from head, OS/2 and post at Parse time. They
|
||||||
|
// back the PDF FontDescriptor accessors and general layout use (descriptor.go).
|
||||||
|
xMin, yMin, xMax, yMax int // head glyph bounding box, font units
|
||||||
|
macStyle uint16 // head macStyle bits (bit 0 bold, bit 1 italic)
|
||||||
|
|
||||||
|
os2Present bool // an OS/2 table was present and long enough to decode
|
||||||
|
os2Version int // OS/2 table version (governs the availability of x/CapHeight)
|
||||||
|
weightClass int // OS/2 usWeightClass (100..900), 0 when absent
|
||||||
|
widthClass int // OS/2 usWidthClass (1..9), 0 when absent
|
||||||
|
sFamilyClass int // OS/2 sFamilyClass (high byte is the IBM font class)
|
||||||
|
fsSelection uint16 // OS/2 fsSelection bits (bit 0 italic)
|
||||||
|
sCapHeight int // OS/2 sCapHeight, font units (version >= 2), 0 when absent
|
||||||
|
sxHeight int // OS/2 sxHeight, font units (version >= 2), 0 when absent
|
||||||
|
|
||||||
|
postPresent bool // a post table was present and long enough to decode
|
||||||
|
italicAngle float64 // post italicAngle (degrees, 0 for upright)
|
||||||
|
isFixedPitch bool // post isFixedPitch (monospaced)
|
||||||
|
|
||||||
|
// tables retains the raw, undecoded sfnt table slices keyed by four-byte tag,
|
||||||
|
// so callers can read table bytes (Font.Table/Font.TableTags) and the
|
||||||
|
// subsetting/instancing code can rebuild a container (subset.go, instance.go).
|
||||||
|
tables map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// be16 reads a big-endian uint16 from b[0:2]; the caller guarantees len(b)>=2.
|
||||||
|
func be16(b []byte) uint16 { return binary.BigEndian.Uint16(b) }
|
||||||
|
|
||||||
|
// be32 reads a big-endian uint32 from b[0:4]; the caller guarantees len(b)>=4.
|
||||||
|
func be32(b []byte) uint32 { return binary.BigEndian.Uint32(b) }
|
||||||
|
|
||||||
|
// sbe16 reads a big-endian int16 from b[0:2]; the caller guarantees len(b)>=2.
|
||||||
|
func sbe16(b []byte) int16 { return int16(binary.BigEndian.Uint16(b)) }
|
||||||
|
|
||||||
|
// Recognised sfnt version tags.
|
||||||
|
const (
|
||||||
|
versionTrueType = 0x00010000 // 'glyf' outlines
|
||||||
|
versionTrue = 0x74727565 // "true", Apple TrueType
|
||||||
|
versionOTTO = 0x4F54544F // "OTTO", CFF/OpenType outlines (phase 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse decodes a TrueType/OpenType font from b and returns a Font. The byte
|
||||||
|
// slice is retained (not copied) and must not be mutated by the caller.
|
||||||
|
//
|
||||||
|
// It fails on a corrupt or unsupported container: a bad sfnt magic, truncated
|
||||||
|
// data, or a missing required table — which does not include the character
|
||||||
|
// map: see [Font.HasCharacterMap]. All three outline flavours are supported:
|
||||||
|
// TrueType ('glyf'/'loca'), CFF/OpenType (a "OTTO" sfnt, or any sfnt carrying a
|
||||||
|
// 'CFF ' table, decoded via cff.go) and variable CFF2 (a sfnt carrying a 'CFF2'
|
||||||
|
// table, decoded via cff2.go).
|
||||||
|
func Parse(b []byte) (*Font, error) {
|
||||||
|
if len(b) < 12 {
|
||||||
|
return nil, fmt.Errorf("opentype: short header: %w", errTruncated)
|
||||||
|
}
|
||||||
|
version := be32(b)
|
||||||
|
switch version {
|
||||||
|
case versionTrueType, versionTrue, versionOTTO:
|
||||||
|
// supported: TrueType ('glyf') and CFF/OpenType ("OTTO") outlines.
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: bad sfnt version 0x%08x", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
numTables := int(be16(b[4:]))
|
||||||
|
tables := make(map[string][]byte, numTables)
|
||||||
|
// The table directory is numTables records of 16 bytes starting at 12.
|
||||||
|
if 12+numTables*16 > len(b) {
|
||||||
|
return nil, fmt.Errorf("opentype: table directory: %w", errTruncated)
|
||||||
|
}
|
||||||
|
for i := 0; i < numTables; i++ {
|
||||||
|
rec := b[12+i*16:]
|
||||||
|
tag := string(rec[0:4])
|
||||||
|
off := int(be32(rec[8:]))
|
||||||
|
length := int(be32(rec[12:]))
|
||||||
|
if off+length > len(b) {
|
||||||
|
return nil, fmt.Errorf("opentype: table %q out of range", tag)
|
||||||
|
}
|
||||||
|
tables[tag] = b[off : off+length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outline flavour is CFF/CFF2 when the sfnt is "OTTO" or carries a 'CFF '
|
||||||
|
// or 'CFF2' table; such a font needs its CFF table but not 'glyf'/'loca'. A
|
||||||
|
// 'CFF2' table (variable CFF) takes precedence and requires 'CFF2'; otherwise
|
||||||
|
// a CFF font requires 'CFF '. A TrueType font needs 'glyf'/'loca'.
|
||||||
|
_, hasCFF := tables["CFF "]
|
||||||
|
_, hasCFF2 := tables["CFF2"]
|
||||||
|
isCFF := version == versionOTTO || hasCFF || hasCFF2
|
||||||
|
// A 'cmap' is not among them. A font embedded in a PDF as a CIDFontType2
|
||||||
|
// is addressed by glyph number through the document's own map, and the
|
||||||
|
// subset that is embedded routinely leaves its character map out; refusing
|
||||||
|
// such a font would leave every glyph in it undrawable for want of a table
|
||||||
|
// nothing was going to consult.
|
||||||
|
required := []string{"head", "maxp", "hhea", "hmtx"}
|
||||||
|
switch {
|
||||||
|
case hasCFF2:
|
||||||
|
required = append(required, "CFF2")
|
||||||
|
case isCFF:
|
||||||
|
required = append(required, "CFF ")
|
||||||
|
default:
|
||||||
|
required = append(required, "loca", "glyf")
|
||||||
|
}
|
||||||
|
for _, name := range required {
|
||||||
|
if _, ok := tables[name]; !ok {
|
||||||
|
return nil, fmt.Errorf("opentype: missing required table %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &Font{tables: tables}
|
||||||
|
if err := f.parseHead(tables["head"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.parseMaxp(tables["maxp"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.parseHhea(tables["hhea"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.parseHmtx(tables["hmtx"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case hasCFF2:
|
||||||
|
c2, err := parseCFF2(tables["CFF2"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.cff2 = c2
|
||||||
|
case isCFF:
|
||||||
|
cff, err := parseCFF(tables["CFF "])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.cff = cff
|
||||||
|
default:
|
||||||
|
if err := f.parseLoca(tables["loca"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.glyf = tables["glyf"]
|
||||||
|
}
|
||||||
|
if cm, ok := tables["cmap"]; ok {
|
||||||
|
if err := f.parseCmap(cm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Optional TrueType instruction tables. Absence is not an error; it just
|
||||||
|
// means the font carries no hinting program (see hint.go).
|
||||||
|
f.fpgm = tables["fpgm"]
|
||||||
|
f.prep = tables["prep"]
|
||||||
|
f.parseCvt(tables["cvt "])
|
||||||
|
// Optional OpenType Font Variations tables. A font without them parses and
|
||||||
|
// renders exactly as before; when present they enable instancing at a
|
||||||
|
// chosen axis coordinate (see fvar.go, avar.go, gvar.go).
|
||||||
|
if err := f.parseVariations(tables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Optional metric-variation tables (HVAR, VVAR, MVAR): advance widths,
|
||||||
|
// advance heights and global font metrics that track the axis coordinate.
|
||||||
|
// Absence is not an error (see metricvar.go).
|
||||||
|
if err := f.parseMetricVariations(tables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Optional OpenType Layout tables: substitution (GSUB), positioning (GPOS)
|
||||||
|
// and the legacy kern table. Absence is not an error (see gsub.go, gpos.go,
|
||||||
|
// kern.go); they power Face.Shape and Face.Kern.
|
||||||
|
if err := f.parseLayout(tables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Optional vertical writing-mode tables (vhea, vmtx, VORG). Absence is not an
|
||||||
|
// error; they supply the metrics for top-to-bottom layout (see vertical.go).
|
||||||
|
if err := f.parseVertical(tables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Optional OpenType MATH table: the font-level metrics a math-typesetting
|
||||||
|
// engine consumes. Absence is not an error (see math.go).
|
||||||
|
if err := f.parseMath(tables); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Optional descriptor tables (OS/2, post): the metadata a PDF FontDescriptor
|
||||||
|
// and general styling consume. Absence is not an error (see descriptor.go).
|
||||||
|
f.parseDescriptor(tables)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLayout decodes the optional GSUB, GPOS and kern tables. Each is
|
||||||
|
// independent; any subset (or none) may be present, and a malformed table is
|
||||||
|
// reported as an error so a corrupt font fails cleanly.
|
||||||
|
func (f *Font) parseLayout(tables map[string][]byte) error {
|
||||||
|
// GDEF is parsed first so its glyph classes and mark sets can be shared with
|
||||||
|
// the GSUB and GPOS appliers, which honour it through their lookup flags.
|
||||||
|
if b, ok := tables["GDEF"]; ok {
|
||||||
|
gd, err := parseGDEF(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.gdef = gd
|
||||||
|
}
|
||||||
|
if b, ok := tables["GSUB"]; ok {
|
||||||
|
g, err := parseGSUB(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.gdef = f.gdef
|
||||||
|
f.gsub = g
|
||||||
|
}
|
||||||
|
if b, ok := tables["GPOS"]; ok {
|
||||||
|
g, err := parseGPOS(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.gdef = f.gdef
|
||||||
|
f.gpos = g
|
||||||
|
}
|
||||||
|
if b, ok := tables["kern"]; ok {
|
||||||
|
k, err := parseKern(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.kern = k
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCvt decodes the Control Value Table: a run of int16 FUnit values. A
|
||||||
|
// missing or odd-length table yields as many whole entries as fit.
|
||||||
|
func (f *Font) parseCvt(b []byte) {
|
||||||
|
f.cvt = make([]int16, len(b)/2)
|
||||||
|
for i := range f.cvt {
|
||||||
|
f.cvt[i] = sbe16(b[i*2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumGlyphs returns the number of glyphs in the font (from the maxp table).
|
||||||
|
func (f *Font) NumGlyphs() int { return f.numGlyphs }
|
||||||
|
|
||||||
|
// GlyphIndex maps a rune to its glyph index via the selected cmap subtable.
|
||||||
|
// ok is false when the rune has no glyph in that subtable, and for a font that
|
||||||
|
// carries no character map at all — one addressed by glyph number, which
|
||||||
|
// [Font.HasCharacterMap] reports.
|
||||||
|
func (f *Font) GlyphIndex(r rune) (GlyphIndex, bool) {
|
||||||
|
if f.cmap == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.cmap.lookup(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCharacterMap reports whether the font can say which glyph a rune is. A
|
||||||
|
// font subset embedded in a PDF as a CIDFontType2 usually cannot: the document
|
||||||
|
// carries that map instead, and the font is addressed by glyph number.
|
||||||
|
func (f *Font) HasCharacterMap() bool { return f.cmap != nil }
|
||||||
|
|
||||||
|
// GlyphIndexVariation resolves a Unicode variation sequence (a base rune
|
||||||
|
// followed by a variation selector) to a glyph index, using the font's
|
||||||
|
// format-14 cmap subtable.
|
||||||
|
//
|
||||||
|
// ok is false when the font has no format-14 subtable, the variation
|
||||||
|
// selector is not registered in it, or the sequence is registered as a
|
||||||
|
// "default" mapping whose base rune has no glyph in the ordinary cmap.
|
||||||
|
func (f *Font) GlyphIndexVariation(r, vs rune) (GlyphIndex, bool) {
|
||||||
|
if f.cmapVS == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.cmapVS.lookupVariation(f, r, vs)
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file implements the 'fvar' (Font Variations) table: the declaration of
|
||||||
|
// a variable font's design-variation axes and its named instances. Together
|
||||||
|
// with 'avar' (avar.go) and 'gvar' (gvar.go) it lets a variable font be
|
||||||
|
// instantiated at a chosen point in its design space instead of only at its
|
||||||
|
// default master.
|
||||||
|
//
|
||||||
|
// The three tables are parsed as optional add-ons: a font without them is a
|
||||||
|
// plain static font and is unaffected. Metric variations (HVAR/VVAR/MVAR) are
|
||||||
|
// implemented separately in metricvar.go; CFF2 outline variation lives in
|
||||||
|
// cff2.go. Deferred (not implemented here): style-attribute variations (STAT).
|
||||||
|
|
||||||
|
// Axis is one design-variation axis of a variable font, decoded from the
|
||||||
|
// 'fvar' table. Min, Default and Max are user-space coordinates (for example
|
||||||
|
// 100, 400 and 900 for a typical weight axis).
|
||||||
|
type Axis struct {
|
||||||
|
Tag string // four-character axis tag, e.g. "wght", "wdth", "slnt"
|
||||||
|
Min float64 // minimum user coordinate
|
||||||
|
Default float64 // default user coordinate (the font's default master)
|
||||||
|
Max float64 // maximum user coordinate
|
||||||
|
Flags uint16 // axis flags (bit 0: hidden axis)
|
||||||
|
NameID uint16 // 'name' table entry describing the axis
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedInstance is a named position in the variation space (for example
|
||||||
|
// "Bold" or "Condensed"), decoded from the 'fvar' table.
|
||||||
|
type NamedInstance struct {
|
||||||
|
SubfamilyNameID uint16 // 'name' entry for the instance subfamily
|
||||||
|
Flags uint16 // instance flags (reserved)
|
||||||
|
Coordinates map[string]float64 // axis tag -> user coordinate
|
||||||
|
PostScriptNameID uint16 // 'name' entry for the PostScript name, 0 if absent
|
||||||
|
}
|
||||||
|
|
||||||
|
// fvarTable is the parsed 'fvar' table.
|
||||||
|
type fvarTable struct {
|
||||||
|
axes []Axis
|
||||||
|
instances []NamedInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVariations parses the optional variation tables. Each is independent:
|
||||||
|
// any subset (or none) may be present. A malformed table is reported as an
|
||||||
|
// error so a corrupt font fails cleanly rather than silently mis-rendering.
|
||||||
|
func (f *Font) parseVariations(tables map[string][]byte) error {
|
||||||
|
if b, ok := tables["fvar"]; ok {
|
||||||
|
if err := f.parseFvar(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["avar"]; ok {
|
||||||
|
if err := f.parseAvar(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["gvar"]; ok {
|
||||||
|
if err := f.parseGvar(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTag reads a four-byte OpenType tag as a string.
|
||||||
|
func readTag(r *reader) string {
|
||||||
|
if r.pos+4 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := string(r.b[r.pos : r.pos+4])
|
||||||
|
r.pos += 4
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFixed reads a 16.16 signed fixed-point value as a float64.
|
||||||
|
func readFixed(r *reader) float64 {
|
||||||
|
return float64(int32(r.u32())) / 65536.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFvar decodes the 'fvar' table: a header, an array of axis records and
|
||||||
|
// an array of named-instance records. The header carries the offset to the
|
||||||
|
// axis array and the (forward-compatible) record sizes, which are honoured so
|
||||||
|
// that unknown trailing fields are skipped.
|
||||||
|
func (f *Font) parseFvar(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.u16() // majorVersion
|
||||||
|
r.u16() // minorVersion
|
||||||
|
axesOffset := int(r.u16())
|
||||||
|
r.u16() // reserved
|
||||||
|
axisCount := int(r.u16())
|
||||||
|
axisSize := int(r.u16())
|
||||||
|
instanceCount := int(r.u16())
|
||||||
|
instanceSize := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: fvar table: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
axes := make([]Axis, axisCount)
|
||||||
|
for i := range axes {
|
||||||
|
ar := reader{b: b, pos: axesOffset + i*axisSize}
|
||||||
|
tag := readTag(&ar)
|
||||||
|
min := readFixed(&ar)
|
||||||
|
def := readFixed(&ar)
|
||||||
|
max := readFixed(&ar)
|
||||||
|
flags := ar.u16()
|
||||||
|
nameID := ar.u16()
|
||||||
|
if ar.err != nil {
|
||||||
|
return fmt.Errorf("opentype: fvar axis %d: %w", i, ar.err)
|
||||||
|
}
|
||||||
|
axes[i] = Axis{Tag: tag, Min: min, Default: def, Max: max, Flags: flags, NameID: nameID}
|
||||||
|
}
|
||||||
|
|
||||||
|
instances := make([]NamedInstance, instanceCount)
|
||||||
|
for i := range instances {
|
||||||
|
ir := reader{b: b, pos: axesOffset + axisCount*axisSize + i*instanceSize}
|
||||||
|
sub := ir.u16()
|
||||||
|
flags := ir.u16()
|
||||||
|
coords := make(map[string]float64, axisCount)
|
||||||
|
for a := 0; a < axisCount; a++ {
|
||||||
|
coords[axes[a].Tag] = readFixed(&ir)
|
||||||
|
}
|
||||||
|
var psID uint16
|
||||||
|
if instanceSize >= axisCount*4+6 {
|
||||||
|
psID = ir.u16()
|
||||||
|
}
|
||||||
|
if ir.err != nil {
|
||||||
|
return fmt.Errorf("opentype: fvar instance %d: %w", i, ir.err)
|
||||||
|
}
|
||||||
|
instances[i] = NamedInstance{
|
||||||
|
SubfamilyNameID: sub,
|
||||||
|
Flags: flags,
|
||||||
|
Coordinates: coords,
|
||||||
|
PostScriptNameID: psID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.fvar = &fvarTable{axes: axes, instances: instances}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axes returns the font's variation axes, or nil for a non-variable font.
|
||||||
|
func (f *Font) Axes() []Axis {
|
||||||
|
if f.fvar == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.fvar.axes
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedInstances returns the font's named instances, or nil if the font is not
|
||||||
|
// variable or declares none.
|
||||||
|
func (f *Font) NamedInstances() []NamedInstance {
|
||||||
|
if f.fvar == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.fvar.instances
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeCoords converts a set of user-space axis coordinates (keyed by axis
|
||||||
|
// tag) into per-axis normalized F2Dot14 values in the range [-1, 1], the form
|
||||||
|
// gvar and avar operate in. Axes not present in user take their default (which
|
||||||
|
// normalizes to 0). Values outside an axis's [min, max] are clamped. If the
|
||||||
|
// font declares an 'avar' table its segment maps are applied. The result has
|
||||||
|
// one entry per fvar axis, in axis order; it is nil for a non-variable font.
|
||||||
|
func (f *Font) NormalizeCoords(user map[string]float64) []int16 {
|
||||||
|
if f.fvar == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
axes := f.fvar.axes
|
||||||
|
out := make([]int16, len(axes))
|
||||||
|
for i, ax := range axes {
|
||||||
|
v := ax.Default
|
||||||
|
if u, ok := user[ax.Tag]; ok {
|
||||||
|
v = u
|
||||||
|
}
|
||||||
|
if v < ax.Min {
|
||||||
|
v = ax.Min
|
||||||
|
}
|
||||||
|
if v > ax.Max {
|
||||||
|
v = ax.Max
|
||||||
|
}
|
||||||
|
|
||||||
|
// v is clamped to [min, max]; whenever v < default the axis has
|
||||||
|
// default > min, and whenever v > default it has max > default, so the
|
||||||
|
// divisors below are always positive. v == default yields n == 0.
|
||||||
|
var n float64
|
||||||
|
switch {
|
||||||
|
case v < ax.Default:
|
||||||
|
n = (v - ax.Default) / (ax.Default - ax.Min)
|
||||||
|
case v > ax.Default:
|
||||||
|
n = (v - ax.Default) / (ax.Max - ax.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.avar != nil {
|
||||||
|
n = f.avar.apply(i, n)
|
||||||
|
}
|
||||||
|
if n < -1 {
|
||||||
|
n = -1
|
||||||
|
}
|
||||||
|
if n > 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
out[i] = int16(math.Round(n * 16384))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file decodes the GDEF (Glyph Definition) table and implements the
|
||||||
|
// OpenType Layout lookup flags that GSUB and GPOS honour during matching. GDEF
|
||||||
|
// classifies glyphs (base / ligature / mark / component), assigns marks to
|
||||||
|
// attachment classes and defines mark glyph sets; the lookupFlag of each lookup
|
||||||
|
// then selects, via those classifications, which glyphs a lookup skips as it
|
||||||
|
// walks the run. Without a GDEF table (or with a zero lookupFlag) nothing is
|
||||||
|
// skipped and matching is identical to a plain contiguous walk.
|
||||||
|
//
|
||||||
|
// The flag bits (OpenType "LookupFlag") are:
|
||||||
|
//
|
||||||
|
// - RightToLeft (0x0001): cursive attachment joins right-to-left.
|
||||||
|
// - IgnoreBaseGlyphs (0x0002): skip GDEF class 1 (base) glyphs.
|
||||||
|
// - IgnoreLigatures (0x0004): skip GDEF class 2 (ligature) glyphs.
|
||||||
|
// - IgnoreMarks (0x0008): skip GDEF class 3 (mark) glyphs.
|
||||||
|
// - UseMarkFilteringSet (0x0010): skip marks absent from the referenced
|
||||||
|
// mark glyph set (its index is the lookup's markFilteringSet field).
|
||||||
|
// - MarkAttachmentType (0xFF00): skip marks whose GDEF mark-attachment class
|
||||||
|
// differs from the type in the flag's high byte.
|
||||||
|
|
||||||
|
// lookupFlag bit masks.
|
||||||
|
const (
|
||||||
|
flagRightToLeft = 0x0001
|
||||||
|
flagIgnoreBaseGlyphs = 0x0002
|
||||||
|
flagIgnoreLigatures = 0x0004
|
||||||
|
flagIgnoreMarks = 0x0008
|
||||||
|
flagUseMarkFilteringSet = 0x0010
|
||||||
|
flagMarkAttachTypeMask = 0xFF00
|
||||||
|
)
|
||||||
|
|
||||||
|
// GDEF GlyphClassDef class values.
|
||||||
|
const (
|
||||||
|
gdefClassBase = 1 // simple glyph (a letter)
|
||||||
|
gdefClassLigature = 2 // ligature glyph (multiple combined glyphs)
|
||||||
|
gdefClassMark = 3 // combining mark (diacritic)
|
||||||
|
gdefClassComponent = 4 // ligature component (part of a ligature)
|
||||||
|
)
|
||||||
|
|
||||||
|
// gdefTable is a decoded GDEF table. All three maps/slices are optional (an
|
||||||
|
// absent subtable yields an empty map or nil slice); a font may carry a GDEF
|
||||||
|
// table with only some of them present.
|
||||||
|
type gdefTable struct {
|
||||||
|
glyphClass map[GlyphIndex]int // GlyphClassDef: base/ligature/mark/component
|
||||||
|
markAttach map[GlyphIndex]int // MarkAttachClassDef: mark-attachment class per mark
|
||||||
|
markGlyphSets []map[GlyphIndex]int // MarkGlyphSets: one Coverage per set
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGDEF decodes a GDEF table. AttachList and LigCaretList are tolerated but
|
||||||
|
// not decoded (they carry hinting/caret data GSUB/GPOS matching does not need).
|
||||||
|
func parseGDEF(b []byte) (*gdefTable, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // majorVersion
|
||||||
|
minor := r.u16()
|
||||||
|
glyphClassOff := int(r.u16())
|
||||||
|
r.skip(2) // attachListOffset (tolerated, not decoded)
|
||||||
|
r.skip(2) // ligCaretListOffset (tolerated, not decoded)
|
||||||
|
markAttachOff := int(r.u16())
|
||||||
|
markSetsOff := 0
|
||||||
|
if minor >= 2 {
|
||||||
|
markSetsOff = int(r.u16())
|
||||||
|
}
|
||||||
|
// minor >= 3 adds an itemVarStore Offset32 here; it is not needed for
|
||||||
|
// matching and is left unread.
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: gdef header: %w", r.err)
|
||||||
|
}
|
||||||
|
t := &gdefTable{glyphClass: map[GlyphIndex]int{}, markAttach: map[GlyphIndex]int{}}
|
||||||
|
if glyphClassOff != 0 {
|
||||||
|
cd, err := parseClassDef(subslice(b, glyphClassOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.glyphClass = cd
|
||||||
|
}
|
||||||
|
if markAttachOff != 0 {
|
||||||
|
cd, err := parseClassDef(subslice(b, markAttachOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.markAttach = cd
|
||||||
|
}
|
||||||
|
if markSetsOff != 0 {
|
||||||
|
sets, err := parseMarkGlyphSets(subslice(b, markSetsOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.markGlyphSets = sets
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMarkGlyphSets decodes a MarkGlyphSetsDef table: a format, a set count and
|
||||||
|
// that many Offset32 to Coverage tables (from the start of b).
|
||||||
|
func parseMarkGlyphSets(b []byte) ([]map[GlyphIndex]int, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // format (1)
|
||||||
|
n := int(r.u16())
|
||||||
|
offs := make([]int, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
offs[i] = int(r.u32())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: markGlyphSets: %w", r.err)
|
||||||
|
}
|
||||||
|
sets := make([]map[GlyphIndex]int, n)
|
||||||
|
for i, off := range offs {
|
||||||
|
cov, err := parseCoverage(subslice(b, off))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sets[i] = cov
|
||||||
|
}
|
||||||
|
return sets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markSetCovers reports whether mark glyph g is a member of mark glyph set i.
|
||||||
|
func (t *gdefTable) markSetCovers(i int, g GlyphIndex) bool {
|
||||||
|
if i < 0 || i >= len(t.markGlyphSets) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := t.markGlyphSets[i][g]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipper decides which glyphs a lookup ignores while matching, from the
|
||||||
|
// lookup's flag (and mark-filtering-set index) against the font's GDEF table. A
|
||||||
|
// zero-value skipper (no GDEF) skips nothing, so matching degrades to a plain
|
||||||
|
// contiguous walk and behaviour is identical to a font without GDEF.
|
||||||
|
type skipper struct {
|
||||||
|
gdef *gdefTable
|
||||||
|
flag uint16
|
||||||
|
markFilterSet uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// rtl reports whether the lookup's RightToLeft flag is set (consulted by
|
||||||
|
// cursive attachment).
|
||||||
|
func (sk skipper) rtl() bool { return sk.flag&flagRightToLeft != 0 }
|
||||||
|
|
||||||
|
// isMark reports whether g is a GDEF mark glyph (class 3). It is false without a
|
||||||
|
// GDEF table, so mark-aware base finding degrades to the plain search.
|
||||||
|
func (sk skipper) isMark(g GlyphIndex) bool {
|
||||||
|
return sk.gdef != nil && sk.gdef.glyphClass[g] == gdefClassMark
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip reports whether glyph g is ignored by this lookup: a base/ligature/mark
|
||||||
|
// the corresponding Ignore flag suppresses, or a mark filtered out by the
|
||||||
|
// mark-attachment type or the mark-filtering set.
|
||||||
|
func (sk skipper) skip(g GlyphIndex) bool {
|
||||||
|
if sk.gdef == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
class := sk.gdef.glyphClass[g]
|
||||||
|
if sk.flag&flagIgnoreBaseGlyphs != 0 && class == gdefClassBase {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sk.flag&flagIgnoreLigatures != 0 && class == gdefClassLigature {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sk.flag&flagIgnoreMarks != 0 && class == gdefClassMark {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if class == gdefClassMark {
|
||||||
|
if sk.flag&flagUseMarkFilteringSet != 0 {
|
||||||
|
return !sk.gdef.markSetCovers(int(sk.markFilterSet), g)
|
||||||
|
}
|
||||||
|
if attach := sk.flag & flagMarkAttachTypeMask; attach != 0 {
|
||||||
|
return sk.gdef.markAttach[g] != int(attach>>8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// next returns the index of the first non-skipped glyph at position >= from, or
|
||||||
|
// len(g) when none remains.
|
||||||
|
func (sk skipper) next(g []GlyphIndex, from int) int {
|
||||||
|
for from < len(g) && sk.skip(g[from]) {
|
||||||
|
from++
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// prev returns the index of the first non-skipped glyph at position <= from, or
|
||||||
|
// -1 when none remains.
|
||||||
|
func (sk skipper) prev(g []GlyphIndex, from int) int {
|
||||||
|
for from >= 0 && sk.skip(g[from]) {
|
||||||
|
from--
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphPred tests one glyph during sequence matching (by glyph id, class or
|
||||||
|
// coverage membership, depending on the sub-format).
|
||||||
|
type glyphPred func(GlyphIndex) bool
|
||||||
|
|
||||||
|
// matchForward matches preds against successive non-skipped positions starting
|
||||||
|
// at the first non-skipped glyph at or after `from`. It returns the matched
|
||||||
|
// absolute positions, the index just past the last match (where a following
|
||||||
|
// lookahead search begins), and whether every predicate matched. An empty preds
|
||||||
|
// list matches trivially at `from`.
|
||||||
|
func (sk skipper) matchForward(g []GlyphIndex, from int, preds []glyphPred) (pos []int, end int, ok bool) {
|
||||||
|
pos = make([]int, len(preds))
|
||||||
|
p := from
|
||||||
|
for k, pred := range preds {
|
||||||
|
p = sk.next(g, p)
|
||||||
|
if p >= len(g) || !pred(g[p]) {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
pos[k] = p
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
return pos, p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchBackward matches preds against successive non-skipped positions walking
|
||||||
|
// backward from `from` (used for backtrack sequences, which are stored in the
|
||||||
|
// reverse of text order).
|
||||||
|
func (sk skipper) matchBackward(g []GlyphIndex, from int, preds []glyphPred) bool {
|
||||||
|
p := from
|
||||||
|
for _, pred := range preds {
|
||||||
|
p = sk.prev(g, p)
|
||||||
|
if p < 0 || !pred(g[p]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p--
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchContext matches a format-3 coverage input sequence: covs[0] must cover
|
||||||
|
// the anchor glyph g[i], and covs[1:] must cover successive non-skipped
|
||||||
|
// positions after i. It returns the matched absolute positions (starting with
|
||||||
|
// i), the index just past the input and whether it matched. An empty covs list
|
||||||
|
// does not match.
|
||||||
|
func (sk skipper) matchContext(g []GlyphIndex, i int, covs []map[GlyphIndex]int) (pos []int, end int, ok bool) {
|
||||||
|
if len(covs) == 0 {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
if _, ok := covs[0][g[i]]; !ok {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
rest, end, ok := sk.matchForward(g, i+1, covPreds(covs[1:]))
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
return append([]int{i}, rest...), end, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// covPreds builds coverage-membership predicates from a list of Coverage maps.
|
||||||
|
func covPreds(covs []map[GlyphIndex]int) []glyphPred {
|
||||||
|
preds := make([]glyphPred, len(covs))
|
||||||
|
for k := range covs {
|
||||||
|
c := covs[k]
|
||||||
|
preds[k] = func(g GlyphIndex) bool { _, ok := c[g]; return ok }
|
||||||
|
}
|
||||||
|
return preds
|
||||||
|
}
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// outlinePoint is one point of a glyph contour in font units. on reports
|
||||||
|
// whether it lies on the curve (true) or is an off-curve quadratic control
|
||||||
|
// point (false).
|
||||||
|
type outlinePoint struct {
|
||||||
|
x, y float64
|
||||||
|
on bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// contour is one closed sequence of outline points.
|
||||||
|
type contour []outlinePoint
|
||||||
|
|
||||||
|
// Simple-glyph flag bits (TrueType spec).
|
||||||
|
const (
|
||||||
|
flagOnCurve = 0x01
|
||||||
|
flagXShort = 0x02
|
||||||
|
flagYShort = 0x04
|
||||||
|
flagRepeat = 0x08
|
||||||
|
flagXSamePosX = 0x10 // X is same (long form) or positive (short form)
|
||||||
|
flagYSamePosY = 0x20 // Y is same (long form) or positive (short form)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Composite-glyph flag bits (TrueType spec).
|
||||||
|
const (
|
||||||
|
compArgsAreWords = 0x0001
|
||||||
|
compArgsAreXY = 0x0002
|
||||||
|
compWeHaveScale = 0x0008
|
||||||
|
compMoreComponents = 0x0020
|
||||||
|
compXAndYScale = 0x0040
|
||||||
|
compTwoByTwo = 0x0080
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxCompositeDepth bounds composite-glyph recursion; anything deeper is
|
||||||
|
// rejected as malformed (in addition to explicit cycle detection).
|
||||||
|
const maxCompositeDepth = 8
|
||||||
|
|
||||||
|
// glyphContours returns the outline of glyph gid, resolving composites, with
|
||||||
|
// a fresh recursion-tracking state. It returns the default-master outline; a
|
||||||
|
// nil norm disables variation (see loadGlyph).
|
||||||
|
func (f *Font) glyphContours(gid GlyphIndex) ([]contour, error) {
|
||||||
|
return f.loadGlyph(gid, 0, map[GlyphIndex]bool{}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphInstructions returns the TrueType instruction bytecode attached to a
|
||||||
|
// simple glyph gid, or nil for an empty glyph, a composite glyph, or a glyph
|
||||||
|
// with no instructions. It is only called after glyphContours has successfully
|
||||||
|
// decoded gid, so the loca range and the simple-glyph header are known valid.
|
||||||
|
func (f *Font) glyphInstructions(gid GlyphIndex) []byte {
|
||||||
|
start, end := f.loca[gid], f.loca[gid+1]
|
||||||
|
if start == end {
|
||||||
|
return nil // empty glyph (e.g. space)
|
||||||
|
}
|
||||||
|
r := reader{b: f.glyf[start:end]}
|
||||||
|
n := int(r.i16())
|
||||||
|
if n < 0 {
|
||||||
|
return nil // composite glyph: not hinted here
|
||||||
|
}
|
||||||
|
r.skip(8) // xMin, yMin, xMax, yMax
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r.u16() // endPtsOfContours[i]
|
||||||
|
}
|
||||||
|
instrLen := int(r.u16())
|
||||||
|
if instrLen == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.b[r.pos : r.pos+instrLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadGlyph decodes glyph gid's contours in font units. depth and visited
|
||||||
|
// guard composite recursion against over-deep nesting and cycles. norm, when
|
||||||
|
// non-nil, is the normalized variation coordinate at which the glyph is
|
||||||
|
// instanced: simple-glyph points get their 'gvar' deltas (with IUP) and
|
||||||
|
// composite-component offsets get theirs (see gvar.go). A nil norm yields the
|
||||||
|
// unvaried default-master outline.
|
||||||
|
func (f *Font) loadGlyph(gid GlyphIndex, depth int, visited map[GlyphIndex]bool, norm []int16) ([]contour, error) {
|
||||||
|
if depth > maxCompositeDepth {
|
||||||
|
return nil, errors.New("opentype: composite glyph nesting too deep")
|
||||||
|
}
|
||||||
|
if int(gid) >= f.numGlyphs {
|
||||||
|
return nil, fmt.Errorf("opentype: glyph index %d out of range", int(gid))
|
||||||
|
}
|
||||||
|
if visited[gid] {
|
||||||
|
return nil, fmt.Errorf("opentype: cyclic composite glyph %d", int(gid))
|
||||||
|
}
|
||||||
|
start, end := f.loca[gid], f.loca[gid+1]
|
||||||
|
if start > end {
|
||||||
|
return nil, fmt.Errorf("opentype: glyph %d: loca not monotonic", int(gid))
|
||||||
|
}
|
||||||
|
if int(end) > len(f.glyf) {
|
||||||
|
return nil, fmt.Errorf("opentype: glyph %d: %w", int(gid), errTruncated)
|
||||||
|
}
|
||||||
|
if start == end {
|
||||||
|
return nil, nil // empty glyph (e.g. space)
|
||||||
|
}
|
||||||
|
visited[gid] = true
|
||||||
|
defer delete(visited, gid)
|
||||||
|
|
||||||
|
r := reader{b: f.glyf[start:end]}
|
||||||
|
numberOfContours := int(r.i16())
|
||||||
|
r.skip(8) // xMin, yMin, xMax, yMax
|
||||||
|
if numberOfContours >= 0 {
|
||||||
|
contours, err := f.simpleGlyph(&r, numberOfContours)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.varySimple(int(gid), contours, norm), nil
|
||||||
|
}
|
||||||
|
return f.compositeGlyph(int(gid), &r, depth, visited, norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// simpleGlyph decodes a simple (non-composite) glyph's n contours.
|
||||||
|
func (f *Font) simpleGlyph(r *reader, n int) ([]contour, error) {
|
||||||
|
endPts := make([]uint16, n)
|
||||||
|
for i := range endPts {
|
||||||
|
endPts[i] = r.u16()
|
||||||
|
}
|
||||||
|
numPoints := 0
|
||||||
|
if n > 0 {
|
||||||
|
numPoints = int(endPts[n-1]) + 1
|
||||||
|
}
|
||||||
|
instrLen := int(r.u16())
|
||||||
|
r.skip(instrLen)
|
||||||
|
|
||||||
|
flags := make([]uint8, numPoints)
|
||||||
|
for i := 0; i < numPoints; {
|
||||||
|
fl := r.u8()
|
||||||
|
flags[i] = fl
|
||||||
|
i++
|
||||||
|
if fl&flagRepeat != 0 {
|
||||||
|
rep := int(r.u8())
|
||||||
|
for j := 0; j < rep && i < numPoints; j++ {
|
||||||
|
flags[i] = fl
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xs := readCoords(r, flags, numPoints, flagXShort, flagXSamePosX)
|
||||||
|
ys := readCoords(r, flags, numPoints, flagYShort, flagYSamePosY)
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: simple glyph: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contours := make([]contour, n)
|
||||||
|
p := 0
|
||||||
|
for ci := 0; ci < n; ci++ {
|
||||||
|
endp := int(endPts[ci])
|
||||||
|
var cnt contour
|
||||||
|
for ; p <= endp; p++ {
|
||||||
|
cnt = append(cnt, outlinePoint{
|
||||||
|
x: float64(xs[p]),
|
||||||
|
y: float64(ys[p]),
|
||||||
|
on: flags[p]&flagOnCurve != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
contours[ci] = cnt
|
||||||
|
}
|
||||||
|
return contours, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCoords decodes one axis (x or y) of point coordinates, accumulating the
|
||||||
|
// signed deltas per the short/same flag encoding.
|
||||||
|
func readCoords(r *reader, flags []uint8, numPoints int, shortBit, samePosBit uint8) []int {
|
||||||
|
coords := make([]int, numPoints)
|
||||||
|
v := 0
|
||||||
|
for i := 0; i < numPoints; i++ {
|
||||||
|
fl := flags[i]
|
||||||
|
if fl&shortBit != 0 {
|
||||||
|
d := int(r.u8())
|
||||||
|
if fl&samePosBit == 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
v += d
|
||||||
|
} else if fl&samePosBit == 0 {
|
||||||
|
v += int(r.i16())
|
||||||
|
}
|
||||||
|
coords[i] = v
|
||||||
|
}
|
||||||
|
return coords
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphComponent is one decoded component record of a composite glyph: the
|
||||||
|
// referenced glyph, its placement offset (arg1/arg2, when argsAreXY) and its
|
||||||
|
// 2x2 transform (a,b,c,d).
|
||||||
|
type glyphComponent struct {
|
||||||
|
flags uint16
|
||||||
|
gid GlyphIndex
|
||||||
|
arg1, arg2 int
|
||||||
|
a, b, c, d float64
|
||||||
|
argsAreXY bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseComponents decodes a composite glyph's component records; r must be
|
||||||
|
// positioned just past the glyph header. Components are returned in order.
|
||||||
|
func parseComponents(r *reader) ([]glyphComponent, error) {
|
||||||
|
var comps []glyphComponent
|
||||||
|
for {
|
||||||
|
var comp glyphComponent
|
||||||
|
comp.flags = r.u16()
|
||||||
|
comp.gid = GlyphIndex(r.u16())
|
||||||
|
if comp.flags&compArgsAreWords != 0 {
|
||||||
|
comp.arg1 = int(r.i16())
|
||||||
|
comp.arg2 = int(r.i16())
|
||||||
|
} else {
|
||||||
|
comp.arg1 = int(int8(r.u8()))
|
||||||
|
comp.arg2 = int(int8(r.u8()))
|
||||||
|
}
|
||||||
|
comp.argsAreXY = comp.flags&compArgsAreXY != 0
|
||||||
|
comp.a, comp.b, comp.c, comp.d = 1.0, 0.0, 0.0, 1.0
|
||||||
|
switch {
|
||||||
|
case comp.flags&compWeHaveScale != 0:
|
||||||
|
comp.a = r.f2dot14()
|
||||||
|
comp.d = comp.a
|
||||||
|
case comp.flags&compXAndYScale != 0:
|
||||||
|
comp.a = r.f2dot14()
|
||||||
|
comp.d = r.f2dot14()
|
||||||
|
case comp.flags&compTwoByTwo != 0:
|
||||||
|
comp.a = r.f2dot14()
|
||||||
|
comp.b = r.f2dot14()
|
||||||
|
comp.c = r.f2dot14()
|
||||||
|
comp.d = r.f2dot14()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: composite glyph: %w", r.err)
|
||||||
|
}
|
||||||
|
comps = append(comps, comp)
|
||||||
|
if comp.flags&compMoreComponents == 0 {
|
||||||
|
return comps, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compositeGlyph assembles a composite glyph from its components, recursively
|
||||||
|
// decoding and transforming each referenced glyph. When norm is non-nil the
|
||||||
|
// component offsets are first varied by 'gvar' (varyComponentOffsets) and each
|
||||||
|
// component is instanced recursively at the same coordinate.
|
||||||
|
func (f *Font) compositeGlyph(gid int, r *reader, depth int, visited map[GlyphIndex]bool, norm []int16) ([]contour, error) {
|
||||||
|
comps, err := parseComponents(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dxs := make([]float64, len(comps))
|
||||||
|
dys := make([]float64, len(comps))
|
||||||
|
for i := range comps {
|
||||||
|
dxs[i] = float64(comps[i].arg1)
|
||||||
|
dys[i] = float64(comps[i].arg2)
|
||||||
|
}
|
||||||
|
f.varyComponentOffsets(gid, comps, dxs, dys, norm)
|
||||||
|
|
||||||
|
var out []contour
|
||||||
|
for i := range comps {
|
||||||
|
comp := comps[i]
|
||||||
|
if !comp.argsAreXY {
|
||||||
|
return nil, errors.New("opentype: unsupported: composite point matching")
|
||||||
|
}
|
||||||
|
sub, err := f.loadGlyph(comp.gid, depth+1, visited, norm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, transformContours(sub, comp.a, comp.b, comp.c, comp.d, dxs[i], dys[i])...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformContours applies the affine transform [a b c d] with translation
|
||||||
|
// (dx, dy) to every point of cs, returning fresh contours.
|
||||||
|
func transformContours(cs []contour, a, b, c, d, dx, dy float64) []contour {
|
||||||
|
out := make([]contour, len(cs))
|
||||||
|
for ci, cont := range cs {
|
||||||
|
nc := make(contour, len(cont))
|
||||||
|
for i, pt := range cont {
|
||||||
|
nc[i] = outlinePoint{
|
||||||
|
x: a*pt.x + c*pt.y + dx,
|
||||||
|
y: b*pt.x + d*pt.y + dy,
|
||||||
|
on: pt.on,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[ci] = nc
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
// Copyright (c) the go-opentype/opentype authors.
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Point is a 2-D coordinate in a glyph outline.
|
||||||
|
type Point struct{ X, Y float64 }
|
||||||
|
|
||||||
|
// SegmentOp identifies a glyph-outline path command.
|
||||||
|
type SegmentOp uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SegMoveTo starts a new contour at P[0].
|
||||||
|
SegMoveTo SegmentOp = iota
|
||||||
|
// SegLineTo draws a straight line to P[0].
|
||||||
|
SegLineTo
|
||||||
|
// SegQuadTo draws a quadratic Bézier with control point P[0] to endpoint P[1].
|
||||||
|
SegQuadTo
|
||||||
|
// SegClose closes the current contour; it carries no points.
|
||||||
|
SegClose
|
||||||
|
)
|
||||||
|
|
||||||
|
// Segment is one command of a glyph outline. SegMoveTo and SegLineTo use P[0];
|
||||||
|
// SegQuadTo uses P[0] as the quadratic control point and P[1] as the on-curve
|
||||||
|
// endpoint; SegClose uses neither.
|
||||||
|
type Segment struct {
|
||||||
|
Op SegmentOp
|
||||||
|
P [2]Point
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphOutline returns glyph gid's outline as path segments in font design
|
||||||
|
// units, in the font's native Y-up orientation with the pen origin at (0, 0).
|
||||||
|
//
|
||||||
|
// TrueType off-curve points are emitted as [SegQuadTo] control points, with the
|
||||||
|
// implied on-curve midpoint synthesised between two consecutive off-curve
|
||||||
|
// points. CFF/CFF2 outlines are stored by this package pre-flattened to
|
||||||
|
// polylines, so they yield [SegLineTo] segments only.
|
||||||
|
//
|
||||||
|
// ok is false when gid is out of range or its outline is corrupt; a valid but
|
||||||
|
// empty glyph (for example a space) returns nil, true.
|
||||||
|
func (fc *Face) GlyphOutline(gid GlyphIndex) (segs []Segment, ok bool) {
|
||||||
|
cs, err := fc.outline(gid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
segs = appendContour(segs, c)
|
||||||
|
}
|
||||||
|
return segs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mid returns the midpoint of a and b.
|
||||||
|
func mid(a, b outlinePoint) Point {
|
||||||
|
return Point{X: (a.x + b.x) / 2, Y: (a.y + b.y) / 2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendContour converts one closed contour of on/off-curve points into path
|
||||||
|
// segments (a MoveTo, the body, and a Close), following the TrueType rule that
|
||||||
|
// consecutive off-curve points imply an on-curve midpoint between them.
|
||||||
|
func appendContour(dst []Segment, c contour) []Segment {
|
||||||
|
n := len(c)
|
||||||
|
if n == 0 {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the starting on-curve point; an all-off-curve contour starts at the
|
||||||
|
// synthesised midpoint of its last and first points.
|
||||||
|
var start Point
|
||||||
|
var body contour
|
||||||
|
switch {
|
||||||
|
case c[0].on:
|
||||||
|
start, body = Point{c[0].x, c[0].y}, c[1:]
|
||||||
|
case c[n-1].on:
|
||||||
|
start, body = Point{c[n-1].x, c[n-1].y}, c[:n-1]
|
||||||
|
default:
|
||||||
|
start, body = mid(c[n-1], c[0]), c
|
||||||
|
}
|
||||||
|
dst = append(dst, Segment{Op: SegMoveTo, P: [2]Point{start}})
|
||||||
|
|
||||||
|
var ctrl Point
|
||||||
|
haveCtrl := false
|
||||||
|
for _, p := range body {
|
||||||
|
cur := Point{p.x, p.y}
|
||||||
|
if p.on {
|
||||||
|
if haveCtrl {
|
||||||
|
dst = append(dst, Segment{Op: SegQuadTo, P: [2]Point{ctrl, cur}})
|
||||||
|
haveCtrl = false
|
||||||
|
} else {
|
||||||
|
dst = append(dst, Segment{Op: SegLineTo, P: [2]Point{cur}})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if haveCtrl {
|
||||||
|
// Two consecutive off-curve points: emit the implied midpoint.
|
||||||
|
m := Point{(ctrl.X + cur.X) / 2, (ctrl.Y + cur.Y) / 2}
|
||||||
|
dst = append(dst, Segment{Op: SegQuadTo, P: [2]Point{ctrl, m}})
|
||||||
|
}
|
||||||
|
ctrl, haveCtrl = cur, true
|
||||||
|
}
|
||||||
|
if haveCtrl {
|
||||||
|
dst = append(dst, Segment{Op: SegQuadTo, P: [2]Point{ctrl, start}})
|
||||||
|
}
|
||||||
|
return append(dst, Segment{Op: SegClose})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphSVGPath returns glyph gid's outline as an SVG path "d" string at the
|
||||||
|
// face's pixel size, in SVG's Y-down coordinate space with the pen origin at
|
||||||
|
// (0, 0) — so the baseline is y = 0 and ascenders have negative y. Callers place
|
||||||
|
// the glyph by wrapping the path in a translate transform.
|
||||||
|
//
|
||||||
|
// ok is false when gid is out of range or its outline is corrupt; a valid but
|
||||||
|
// empty glyph returns "", true.
|
||||||
|
func (fc *Face) GlyphSVGPath(gid GlyphIndex) (d string, ok bool) {
|
||||||
|
segs, ok := fc.GlyphOutline(gid)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return segmentsToSVG(segs, fc.scale), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// segmentsToSVG renders outline segments (font units, Y-up) into an SVG path
|
||||||
|
// "d" string scaled by scale and flipped to SVG's Y-down space.
|
||||||
|
func segmentsToSVG(segs []Segment, scale float64) string {
|
||||||
|
var b strings.Builder
|
||||||
|
writePoint := func(p Point) {
|
||||||
|
b.WriteString(ftoa(p.X * scale))
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(ftoa(-p.Y * scale))
|
||||||
|
}
|
||||||
|
for _, s := range segs {
|
||||||
|
switch s.Op {
|
||||||
|
case SegMoveTo:
|
||||||
|
b.WriteByte('M')
|
||||||
|
writePoint(s.P[0])
|
||||||
|
case SegLineTo:
|
||||||
|
b.WriteByte('L')
|
||||||
|
writePoint(s.P[0])
|
||||||
|
case SegQuadTo:
|
||||||
|
b.WriteByte('Q')
|
||||||
|
writePoint(s.P[0])
|
||||||
|
b.WriteByte(' ')
|
||||||
|
writePoint(s.P[1])
|
||||||
|
case SegClose:
|
||||||
|
b.WriteByte('Z')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ftoa formats v with up to three decimals and no trailing zeros (so "12.000"
|
||||||
|
// becomes "12" and "1.250" becomes "1.25"), keeping SVG paths compact. The
|
||||||
|
// fixed-precision form always contains a decimal point, so trimming is safe.
|
||||||
|
func ftoa(v float64) string {
|
||||||
|
if v == 0 { // collapse both +0 and the -0 produced by the Y flip
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
s := strings.TrimRight(strconv.FormatFloat(v, 'f', 3, 64), "0")
|
||||||
|
return strings.TrimSuffix(s, ".")
|
||||||
|
}
|
||||||
+1653
File diff suppressed because it is too large
Load Diff
+1197
File diff suppressed because it is too large
Load Diff
+504
@@ -0,0 +1,504 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file implements the optional 'gvar' (Glyph Variations) table for 'glyf'
|
||||||
|
// outlines: the per-glyph store of point deltas that move a glyph's outline
|
||||||
|
// from the default master to a requested position in the variation space.
|
||||||
|
//
|
||||||
|
// A glyph's variation is a set of "tuple variations". Each tuple has a peak
|
||||||
|
// (and optionally an intermediate region) in normalized axis space, a set of
|
||||||
|
// point numbers it touches and a delta (dx, dy) per touched point. At a given
|
||||||
|
// normalized coordinate every tuple gets a scalar (how strongly it applies);
|
||||||
|
// the scaled deltas of all tuples are summed. Deltas for points a tuple does
|
||||||
|
// not touch are recovered by IUP (Interpolation of Untouched Points), done per
|
||||||
|
// contour against the default-master coordinates.
|
||||||
|
//
|
||||||
|
// Both simple and composite glyphs are variable: simple-glyph points get their
|
||||||
|
// deltas with per-contour IUP, while a composite glyph's per-component
|
||||||
|
// placement offsets (and the four phantom points) get theirs directly, with no
|
||||||
|
// IUP (each component point is independent). The four TrueType phantom points
|
||||||
|
// are carried through delta application (so "all points" and explicit point
|
||||||
|
// sets index correctly) but are dropped from the result.
|
||||||
|
//
|
||||||
|
// Metric variation via HVAR/VVAR/MVAR (advance widths, advance heights and
|
||||||
|
// global font metrics) is implemented separately in metricvar.go.
|
||||||
|
|
||||||
|
// gvarTable is the parsed 'gvar' table.
|
||||||
|
type gvarTable struct {
|
||||||
|
axisCount int
|
||||||
|
sharedTuples [][]float64 // sharedTupleCount peak tuples, each axisCount F2Dot14
|
||||||
|
dataOffsets []uint32 // glyphCount+1 offsets into the data array
|
||||||
|
dataArrayOff int // offset of the glyph-variation-data array within data
|
||||||
|
data []byte // the whole 'gvar' table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuple index / header flag bits (gvar TupleVariationHeader.tupleIndex).
|
||||||
|
const (
|
||||||
|
gvEmbeddedPeak = 0x8000 // an embedded peak tuple follows the header
|
||||||
|
gvIntermediate = 0x4000 // embedded intermediate-region tuples follow
|
||||||
|
gvPrivatePoints = 0x2000 // this tuple has private point numbers
|
||||||
|
gvTupleIndexMsk = 0x0FFF // shared-tuple index (when no embedded peak)
|
||||||
|
)
|
||||||
|
|
||||||
|
// gvSharedPointNumbers is the bit in GlyphVariationData.tupleVariationCount
|
||||||
|
// flagging a shared point-number set at the head of the serialized data.
|
||||||
|
const gvSharedPointNumbers = 0x8000
|
||||||
|
|
||||||
|
// parseGvar decodes the 'gvar' header, the per-glyph offset table and the
|
||||||
|
// shared-tuple array. Per-glyph variation data is decoded lazily in
|
||||||
|
// applyVariation, so a glyph is only touched when it is actually instanced.
|
||||||
|
func (f *Font) parseGvar(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.u16() // majorVersion
|
||||||
|
r.u16() // minorVersion
|
||||||
|
axisCount := int(r.u16())
|
||||||
|
sharedTupleCount := int(r.u16())
|
||||||
|
sharedTuplesOffset := int(r.u32())
|
||||||
|
glyphCount := int(r.u16())
|
||||||
|
flags := r.u16()
|
||||||
|
dataArrayOffset := int(r.u32())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar table: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
longOffsets := flags&1 != 0
|
||||||
|
offsets := make([]uint32, glyphCount+1)
|
||||||
|
for i := range offsets {
|
||||||
|
if longOffsets {
|
||||||
|
offsets[i] = r.u32()
|
||||||
|
} else {
|
||||||
|
offsets[i] = uint32(r.u16()) * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar offsets: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := make([][]float64, sharedTupleCount)
|
||||||
|
sr := reader{b: b, pos: sharedTuplesOffset}
|
||||||
|
for i := range shared {
|
||||||
|
t := make([]float64, axisCount)
|
||||||
|
for a := range t {
|
||||||
|
t[a] = sr.f2dot14()
|
||||||
|
}
|
||||||
|
shared[i] = t
|
||||||
|
}
|
||||||
|
if sr.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar shared tuples: %w", sr.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.gvar = &gvarTable{
|
||||||
|
axisCount: axisCount,
|
||||||
|
sharedTuples: shared,
|
||||||
|
dataOffsets: offsets,
|
||||||
|
dataArrayOff: dataArrayOffset,
|
||||||
|
data: b,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVariation returns pts with glyph gid's 'gvar' deltas applied at the
|
||||||
|
// normalized coordinate norm (as produced by NormalizeCoords). pts are the
|
||||||
|
// glyph's default-master outline points in point order; ends holds the last
|
||||||
|
// point index of each contour (as in glyf's endPtsOfContours). Points untouched
|
||||||
|
// by a tuple are filled by per-contour IUP against pts. A font without 'gvar',
|
||||||
|
// a glyph with no variation data, or malformed data yields an unchanged copy.
|
||||||
|
func (f *Font) applyVariation(gid int, pts []outlinePoint, ends []int, norm []int16) []outlinePoint {
|
||||||
|
out := make([]outlinePoint, len(pts))
|
||||||
|
copy(out, pts)
|
||||||
|
|
||||||
|
gv := f.gvar
|
||||||
|
if gv == nil || gid < 0 || gid+1 >= len(gv.dataOffsets) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
start := gv.dataArrayOff + int(gv.dataOffsets[gid])
|
||||||
|
end := gv.dataArrayOff + int(gv.dataOffsets[gid+1])
|
||||||
|
if start >= end || end > len(gv.data) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
coords := make([]float64, gv.axisCount)
|
||||||
|
for i := range coords {
|
||||||
|
if i < len(norm) {
|
||||||
|
coords[i] = float64(norm[i]) / 16384.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
numReal := len(pts)
|
||||||
|
accX := make([]float64, numReal+4) // + four phantom points
|
||||||
|
accY := make([]float64, numReal+4)
|
||||||
|
if err := gv.applyGlyph(gv.data[start:end], coords, ends, pts, accX, accY, false); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for i := 0; i < numReal; i++ {
|
||||||
|
out[i].x += accX[i]
|
||||||
|
out[i].y += accY[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tupleHeader is a decoded TupleVariationHeader: the region it applies to and
|
||||||
|
// the size and point-set kind of its serialized data.
|
||||||
|
type tupleHeader struct {
|
||||||
|
size int // serialized data size in bytes (private points + deltas)
|
||||||
|
peak []float64 // per-axis peak coordinate
|
||||||
|
lower []float64 // per-axis region lower bound
|
||||||
|
upper []float64 // per-axis region upper bound
|
||||||
|
privatePoint bool // has private point numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGlyph decodes one glyph's GlyphVariationData and accumulates every
|
||||||
|
// applicable tuple's scaled deltas into accX/accY (length numPts). For a simple
|
||||||
|
// glyph (composite false) untouched points of an explicit set are filled by
|
||||||
|
// per-contour IUP against pts; for a composite glyph (composite true, ends and
|
||||||
|
// pts nil) each point is a component offset or phantom point and untouched
|
||||||
|
// points simply stay zero — no IUP.
|
||||||
|
func (gv *gvarTable) applyGlyph(gd []byte, coords []float64, ends []int, pts []outlinePoint, accX, accY []float64, composite bool) error {
|
||||||
|
numPts := len(accX)
|
||||||
|
r := reader{b: gd}
|
||||||
|
countAndFlags := r.u16()
|
||||||
|
dataOffset := int(r.u16())
|
||||||
|
tupleCount := int(countAndFlags & gvTupleIndexMsk)
|
||||||
|
hasShared := countAndFlags&gvSharedPointNumbers != 0
|
||||||
|
|
||||||
|
headers := make([]tupleHeader, tupleCount)
|
||||||
|
for i := range headers {
|
||||||
|
h := tupleHeader{
|
||||||
|
size: int(r.u16()),
|
||||||
|
lower: make([]float64, gv.axisCount),
|
||||||
|
upper: make([]float64, gv.axisCount),
|
||||||
|
}
|
||||||
|
idx := r.u16()
|
||||||
|
if idx&gvEmbeddedPeak != 0 {
|
||||||
|
h.peak = make([]float64, gv.axisCount)
|
||||||
|
for a := range h.peak {
|
||||||
|
h.peak[a] = r.f2dot14()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
si := int(idx & gvTupleIndexMsk)
|
||||||
|
if si >= len(gv.sharedTuples) {
|
||||||
|
return fmt.Errorf("opentype: gvar: shared tuple index %d out of range", si)
|
||||||
|
}
|
||||||
|
h.peak = gv.sharedTuples[si]
|
||||||
|
}
|
||||||
|
if idx&gvIntermediate != 0 {
|
||||||
|
for a := 0; a < gv.axisCount; a++ {
|
||||||
|
h.lower[a] = r.f2dot14()
|
||||||
|
}
|
||||||
|
for a := 0; a < gv.axisCount; a++ {
|
||||||
|
h.upper[a] = r.f2dot14()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for a := 0; a < gv.axisCount; a++ {
|
||||||
|
if h.peak[a] < 0 {
|
||||||
|
h.lower[a] = h.peak[a]
|
||||||
|
}
|
||||||
|
if h.peak[a] > 0 {
|
||||||
|
h.upper[a] = h.peak[a]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.privatePoint = idx&gvPrivatePoints != 0
|
||||||
|
headers[i] = h
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar tuple headers: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sd := reader{b: gd, pos: dataOffset}
|
||||||
|
var sharedPoints []int
|
||||||
|
sharedAll := false
|
||||||
|
if hasShared {
|
||||||
|
sharedPoints, sharedAll = readPackedPoints(&sd)
|
||||||
|
}
|
||||||
|
if sd.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar shared points: %w", sd.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := sd.pos
|
||||||
|
for i := range headers {
|
||||||
|
h := headers[i]
|
||||||
|
tr := reader{b: gd, pos: pos}
|
||||||
|
points, all := sharedPoints, sharedAll
|
||||||
|
if h.privatePoint {
|
||||||
|
points, all = readPackedPoints(&tr)
|
||||||
|
}
|
||||||
|
n := len(points)
|
||||||
|
if all {
|
||||||
|
n = numPts
|
||||||
|
}
|
||||||
|
dx := readPackedDeltas(&tr, n)
|
||||||
|
dy := readPackedDeltas(&tr, n)
|
||||||
|
if tr.err != nil {
|
||||||
|
return fmt.Errorf("opentype: gvar tuple %d data: %w", i, tr.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scalar := supportScalar(coords, h.lower, h.peak, h.upper)
|
||||||
|
if scalar != 0 {
|
||||||
|
accumulate(scalar, all, points, dx, dy, ends, pts, accX, accY, !composite)
|
||||||
|
}
|
||||||
|
pos += h.size
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPackedPoints decodes a packed point-number set. all is true for the
|
||||||
|
// "all points" encoding (a leading count of zero), in which case pts is nil.
|
||||||
|
// Numbers are stored as run-length-encoded deltas from the previous number.
|
||||||
|
func readPackedPoints(r *reader) (pts []int, all bool) {
|
||||||
|
first := r.u8()
|
||||||
|
count := int(first)
|
||||||
|
if first&0x80 != 0 {
|
||||||
|
count = int(first&0x7f)<<8 | int(r.u8())
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
pts = make([]int, 0, count)
|
||||||
|
val := 0
|
||||||
|
for len(pts) < count {
|
||||||
|
control := r.u8()
|
||||||
|
run := int(control&0x7f) + 1
|
||||||
|
words := control&0x80 != 0
|
||||||
|
for j := 0; j < run && len(pts) < count; j++ {
|
||||||
|
if words {
|
||||||
|
val += int(r.u16())
|
||||||
|
} else {
|
||||||
|
val += int(r.u8())
|
||||||
|
}
|
||||||
|
pts = append(pts, val)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return pts, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPackedDeltas decodes count run-length-encoded deltas (one axis's worth).
|
||||||
|
func readPackedDeltas(r *reader, count int) []float64 {
|
||||||
|
out := make([]float64, 0, count)
|
||||||
|
for len(out) < count {
|
||||||
|
control := r.u8()
|
||||||
|
run := int(control&0x3f) + 1
|
||||||
|
switch {
|
||||||
|
case control&0x80 != 0: // DELTAS_ARE_ZERO
|
||||||
|
for j := 0; j < run && len(out) < count; j++ {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
case control&0x40 != 0: // DELTAS_ARE_WORDS
|
||||||
|
for j := 0; j < run && len(out) < count; j++ {
|
||||||
|
out = append(out, float64(int16(r.u16())))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for j := 0; j < run && len(out) < count; j++ {
|
||||||
|
out = append(out, float64(int8(r.u8())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportScalar returns how strongly a tuple with the given per-axis region
|
||||||
|
// (lower <= peak <= upper) applies at coords: 1 at the peak, tapering linearly
|
||||||
|
// to 0 at the region edges, and 0 outside. An axis whose peak is 0, or whose
|
||||||
|
// region is degenerate or spans zero, does not constrain the tuple.
|
||||||
|
func supportScalar(coords, lower, peak, upper []float64) float64 {
|
||||||
|
scalar := 1.0
|
||||||
|
for a := range peak {
|
||||||
|
p := peak[a]
|
||||||
|
if p == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lo, up := lower[a], upper[a]
|
||||||
|
if lo > p || p > up {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lo < 0 && up > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := coords[a]
|
||||||
|
if v == p {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v <= lo || up <= v {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v < p {
|
||||||
|
scalar *= (v - lo) / (p - lo)
|
||||||
|
} else {
|
||||||
|
scalar *= (up - v) / (up - p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scalar
|
||||||
|
}
|
||||||
|
|
||||||
|
// accumulate adds one tuple's contribution to accX/accY. For an explicit point
|
||||||
|
// set it places each touched point's delta; iup then fills the untouched points
|
||||||
|
// per contour by interpolation (simple glyphs) or leaves them at zero
|
||||||
|
// (composite glyphs). For the "all points" set every point is touched and
|
||||||
|
// deltas apply directly.
|
||||||
|
func accumulate(scalar float64, all bool, points []int, dx, dy []float64, ends []int, pts []outlinePoint, accX, accY []float64, iup bool) {
|
||||||
|
numPts := len(accX)
|
||||||
|
fullX := make([]float64, numPts)
|
||||||
|
fullY := make([]float64, numPts)
|
||||||
|
|
||||||
|
if all {
|
||||||
|
for i := 0; i < numPts && i < len(dx); i++ {
|
||||||
|
fullX[i] = dx[i]
|
||||||
|
fullY[i] = dy[i]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
touched := make([]bool, numPts)
|
||||||
|
for k, pi := range points {
|
||||||
|
if pi >= numPts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullX[pi] = dx[k]
|
||||||
|
fullY[pi] = dy[k]
|
||||||
|
touched[pi] = true
|
||||||
|
}
|
||||||
|
if iup {
|
||||||
|
startIdx := 0
|
||||||
|
for _, e := range ends {
|
||||||
|
iupContour(pts[startIdx:e+1], fullX[startIdx:e+1], fullY[startIdx:e+1], touched[startIdx:e+1])
|
||||||
|
startIdx = e + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < numPts; i++ {
|
||||||
|
accX[i] += scalar * fullX[i]
|
||||||
|
accY[i] += scalar * fullY[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iupContour fills deltas for the untouched points of a single contour in
|
||||||
|
// place, interpolating each coordinate against the default-master positions in
|
||||||
|
// pts. With no touched point the contour is left unmoved; with exactly one the
|
||||||
|
// whole contour is shifted by that delta; otherwise untouched points between
|
||||||
|
// each pair of consecutive touched points (wrapping around the contour) are
|
||||||
|
// interpolated.
|
||||||
|
func iupContour(pts []outlinePoint, dx, dy []float64, touched []bool) {
|
||||||
|
n := len(pts)
|
||||||
|
var tp []int
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if touched[i] {
|
||||||
|
tp = append(tp, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tp) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tp) == 1 {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
dx[i] = dx[tp[0]]
|
||||||
|
dy[i] = dy[tp[0]]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k := 0; k < len(tp); k++ {
|
||||||
|
a := tp[k]
|
||||||
|
b := tp[(k+1)%len(tp)]
|
||||||
|
for i := (a + 1) % n; i != b; i = (i + 1) % n {
|
||||||
|
dx[i] = iupInterp(pts[i].x, pts[a].x, pts[b].x, dx[a], dx[b])
|
||||||
|
dy[i] = iupInterp(pts[i].y, pts[a].y, pts[b].y, dy[a], dy[b])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iupInterp interpolates the delta of one untouched point at coordinate target,
|
||||||
|
// bracketed by two touched points at coordinates lo/hi with deltas dlo/dhi.
|
||||||
|
// Outside the bracket the nearer touched delta is held constant; inside, the
|
||||||
|
// delta is linearly interpolated by coordinate.
|
||||||
|
func iupInterp(target, lo, hi, dlo, dhi float64) float64 {
|
||||||
|
if lo > hi {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
dlo, dhi = dhi, dlo
|
||||||
|
}
|
||||||
|
if target <= lo {
|
||||||
|
return dlo
|
||||||
|
}
|
||||||
|
if target >= hi {
|
||||||
|
return dhi
|
||||||
|
}
|
||||||
|
return dlo + (dhi-dlo)*(target-lo)/(hi-lo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// varySimple applies glyph gid's 'gvar' point deltas to a simple glyph's
|
||||||
|
// contours at normalized coordinate norm, returning fresh contours. A nil norm
|
||||||
|
// or a font without 'gvar' returns contours unchanged (the default outline).
|
||||||
|
func (f *Font) varySimple(gid int, contours []contour, norm []int16) []contour {
|
||||||
|
if norm == nil || f.gvar == nil {
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
var pts []outlinePoint
|
||||||
|
var ends []int
|
||||||
|
for _, c := range contours {
|
||||||
|
pts = append(pts, c...)
|
||||||
|
ends = append(ends, len(pts)-1)
|
||||||
|
}
|
||||||
|
varied := f.applyVariation(gid, pts, ends, norm)
|
||||||
|
|
||||||
|
out := make([]contour, len(contours))
|
||||||
|
startIdx := 0
|
||||||
|
for i, e := range ends {
|
||||||
|
out[i] = varied[startIdx : e+1]
|
||||||
|
startIdx = e + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// varyComponentOffsets applies glyph gid's 'gvar' deltas to a composite glyph's
|
||||||
|
// component placement offsets in place (dxs/dys, one entry per component). The
|
||||||
|
// gvar point set for a composite indexes the components in order followed by
|
||||||
|
// four phantom points; deltas apply with no IUP. A nil norm, a font without
|
||||||
|
// 'gvar', or malformed data leaves dxs/dys unchanged.
|
||||||
|
func (f *Font) varyComponentOffsets(gid int, comps []glyphComponent, dxs, dys []float64, norm []int16) {
|
||||||
|
if norm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gv := f.gvar
|
||||||
|
if gv == nil || gid < 0 || gid+1 >= len(gv.dataOffsets) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := gv.dataArrayOff + int(gv.dataOffsets[gid])
|
||||||
|
end := gv.dataArrayOff + int(gv.dataOffsets[gid+1])
|
||||||
|
if start >= end || end > len(gv.data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
coords := make([]float64, gv.axisCount)
|
||||||
|
for i := range coords {
|
||||||
|
if i < len(norm) {
|
||||||
|
coords[i] = float64(norm[i]) / 16384.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accX := make([]float64, len(comps)+4) // component offsets + four phantom points
|
||||||
|
accY := make([]float64, len(comps)+4)
|
||||||
|
if err := gv.applyGlyph(gv.data[start:end], coords, nil, nil, accX, accY, true); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range comps {
|
||||||
|
dxs[i] += accX[i]
|
||||||
|
dys[i] += accY[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstancePoints returns glyph gid's outline, as contours in font units,
|
||||||
|
// instanced at the user-space axis coordinates coords (keyed by axis tag).
|
||||||
|
// Axes absent from coords take their default. For a non-variable font, or a
|
||||||
|
// glyph with no variation data, it returns the default outline. Both simple
|
||||||
|
// and composite glyphs are varied: a composite's component offsets get their
|
||||||
|
// deltas and each component is instanced recursively at the same coordinate.
|
||||||
|
func (f *Font) InstancePoints(gid int, coords map[string]float64) ([]contour, error) {
|
||||||
|
norm := f.NormalizeCoords(coords)
|
||||||
|
return f.loadGlyph(GlyphIndex(gid), 0, map[GlyphIndex]bool{}, norm)
|
||||||
|
}
|
||||||
+1755
File diff suppressed because it is too large
Load Diff
+295
@@ -0,0 +1,295 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file bakes a variable ('glyf'/'gvar') font at a chosen axis position into
|
||||||
|
// a plain static font, so an embedder that cannot ship a variable font (a PDF
|
||||||
|
// writer, say) can ship the exact instance a document uses. Every glyph's outline
|
||||||
|
// is instanced (gvar deltas applied, composites flattened) and re-encoded as a
|
||||||
|
// simple glyph, every advance width is instanced (HVAR, or the gvar phantom-point
|
||||||
|
// fallback), and the variation tables (fvar, avar, gvar, HVAR/VVAR/MVAR, STAT) are
|
||||||
|
// dropped so the result advertises — and behaves as — a single static master.
|
||||||
|
//
|
||||||
|
// Scope (documented): the CFF2 (variable CFF) outline path is not baked; a CFF2
|
||||||
|
// font returns an error. TrueType instructions and the shared hinting programs
|
||||||
|
// (cvt/fpgm/prep) are dropped, since flattening composites into simple glyphs
|
||||||
|
// renumbers the points the instructions address; the instance renders unhinted.
|
||||||
|
// Global metric variations (MVAR) are not folded into hhea/OS-2; the instance
|
||||||
|
// keeps the default master's global metrics.
|
||||||
|
|
||||||
|
// glyfWriter is a minimal big-endian byte writer for re-encoding glyf/loca and
|
||||||
|
// patching the cloned metric tables.
|
||||||
|
type glyfWriter struct{ b []byte }
|
||||||
|
|
||||||
|
func (w *glyfWriter) u16(v uint16) { w.b = binary.BigEndian.AppendUint16(w.b, v) }
|
||||||
|
func (w *glyfWriter) i16(v int16) { w.u16(uint16(v)) }
|
||||||
|
|
||||||
|
// Instance bakes the font at the user-space axis coordinates coords into a new,
|
||||||
|
// static Font: a font with the requested variation permanently applied and no
|
||||||
|
// variation axes. Axes absent from coords take their default. It is the parsed
|
||||||
|
// counterpart of InstanceBytes.
|
||||||
|
//
|
||||||
|
// It returns an error for a non-variable font, a CFF2 (variable CFF) font, or when
|
||||||
|
// coords names an axis the font does not have.
|
||||||
|
func (f *Font) Instance(coords map[string]float64) (*Font, error) {
|
||||||
|
b, err := f.InstanceBytes(coords)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Parse(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceBytes bakes the font at coords into the bytes of a new static TrueType
|
||||||
|
// sfnt (see Instance for the semantics). The returned font carries instanced glyf
|
||||||
|
// outlines and advance widths and no variation tables; Instance parses these bytes.
|
||||||
|
func (f *Font) InstanceBytes(coords map[string]float64) ([]byte, error) {
|
||||||
|
if f.cff2 != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: Instance: CFF2 (variable CFF) fonts are not supported")
|
||||||
|
}
|
||||||
|
if f.fvar == nil {
|
||||||
|
return nil, fmt.Errorf("opentype: Instance: font is not variable (no fvar table)")
|
||||||
|
}
|
||||||
|
if f.glyf == nil {
|
||||||
|
return nil, fmt.Errorf("opentype: Instance: font has no glyf outlines to instance")
|
||||||
|
}
|
||||||
|
known := make(map[string]bool, len(f.fvar.axes))
|
||||||
|
for _, a := range f.fvar.axes {
|
||||||
|
known[a.Tag] = true
|
||||||
|
}
|
||||||
|
for tag := range coords {
|
||||||
|
if !known[tag] {
|
||||||
|
return nil, fmt.Errorf("opentype: Instance: unknown axis %q", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
norm := f.NormalizeCoords(coords)
|
||||||
|
|
||||||
|
glyphs := make([][]byte, f.numGlyphs)
|
||||||
|
advances := make([]int, f.numGlyphs)
|
||||||
|
lsbs := make([]int, f.numGlyphs)
|
||||||
|
haveBox := false
|
||||||
|
var xMin, yMin, xMax, yMax int
|
||||||
|
for gid := 0; gid < f.numGlyphs; gid++ {
|
||||||
|
contours, err := f.loadGlyph(GlyphIndex(gid), 0, map[GlyphIndex]bool{}, norm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
glyphs[gid] = encodeSimpleGlyph(contours)
|
||||||
|
advances[gid] = roundInt(float64(f.advances[gid]) + f.horizontalAdvanceDelta(gid, norm))
|
||||||
|
gx0, gy0, gx1, gy1, ok := contourBounds(contours)
|
||||||
|
if ok {
|
||||||
|
lsbs[gid] = gx0
|
||||||
|
if !haveBox {
|
||||||
|
xMin, yMin, xMax, yMax, haveBox = gx0, gy0, gx1, gy1, true
|
||||||
|
} else {
|
||||||
|
xMin, yMin = min(xMin, gx0), min(yMin, gy0)
|
||||||
|
xMax, yMax = max(xMax, gx1), max(yMax, gy1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loca, glyf := packGlyf(glyphs)
|
||||||
|
tables := map[string][]byte{
|
||||||
|
"glyf": glyf,
|
||||||
|
"loca": loca,
|
||||||
|
"head": f.instanceHead(xMin, yMin, xMax, yMax),
|
||||||
|
"hhea": f.subsetHhea(f.numGlyphs),
|
||||||
|
"maxp": instanceMaxp(f.tables["maxp"], glyphs),
|
||||||
|
"hmtx": buildHmtx(advances, lsbs),
|
||||||
|
}
|
||||||
|
// Carry over every non-variation table verbatim. The variation tables and the
|
||||||
|
// hinting tables are dropped (see the file comment); the six rebuilt tables
|
||||||
|
// above are already set and must not be overwritten by the originals.
|
||||||
|
drop := map[string]bool{
|
||||||
|
"fvar": true, "avar": true, "gvar": true,
|
||||||
|
"HVAR": true, "VVAR": true, "MVAR": true, "STAT": true,
|
||||||
|
"cvt ": true, "fpgm": true, "prep": true,
|
||||||
|
"glyf": true, "loca": true, "head": true,
|
||||||
|
"hhea": true, "maxp": true, "hmtx": true,
|
||||||
|
}
|
||||||
|
for tag, b := range f.tables {
|
||||||
|
if drop[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tables[tag] = append([]byte(nil), b...)
|
||||||
|
}
|
||||||
|
return buildSFNT(versionTrueType, tables), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packGlyf concatenates the per-glyph blobs into a glyf table and its matching
|
||||||
|
// long-format loca (numGlyphs+1 offsets), 2-byte-aligning each glyph.
|
||||||
|
func packGlyf(glyphs [][]byte) (loca, glyf []byte) {
|
||||||
|
offsets := make([]uint32, len(glyphs)+1)
|
||||||
|
for i, g := range glyphs {
|
||||||
|
offsets[i] = uint32(len(glyf))
|
||||||
|
glyf = append(glyf, g...)
|
||||||
|
for len(glyf)%2 != 0 {
|
||||||
|
glyf = append(glyf, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offsets[len(glyphs)] = uint32(len(glyf))
|
||||||
|
loca = make([]byte, len(offsets)*4)
|
||||||
|
for i, o := range offsets {
|
||||||
|
binary.BigEndian.PutUint32(loca[i*4:], o)
|
||||||
|
}
|
||||||
|
return loca, glyf
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHmtx builds a full hmtx table: one (advanceWidth, leftSideBearing) pair for
|
||||||
|
// every glyph (numberOfHMetrics equals the glyph count).
|
||||||
|
func buildHmtx(advances, lsbs []int) []byte {
|
||||||
|
w := &glyfWriter{}
|
||||||
|
for i := range advances {
|
||||||
|
w.u16(uint16(advances[i]))
|
||||||
|
w.i16(int16(lsbs[i]))
|
||||||
|
}
|
||||||
|
return w.b
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceHead clones the head table with checkSumAdjustment zeroed (buildSFNT
|
||||||
|
// recomputes it), indexToLocFormat set to long, and the recomputed instanced glyph
|
||||||
|
// bounding box.
|
||||||
|
func (f *Font) instanceHead(xMin, yMin, xMax, yMax int) []byte {
|
||||||
|
head := append([]byte(nil), f.tables["head"]...)
|
||||||
|
binary.BigEndian.PutUint32(head[8:], 0)
|
||||||
|
binary.BigEndian.PutUint16(head[36:], uint16(int16(xMin)))
|
||||||
|
binary.BigEndian.PutUint16(head[38:], uint16(int16(yMin)))
|
||||||
|
binary.BigEndian.PutUint16(head[40:], uint16(int16(xMax)))
|
||||||
|
binary.BigEndian.PutUint16(head[42:], uint16(int16(yMax)))
|
||||||
|
binary.BigEndian.PutUint16(head[50:], 1)
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceMaxp clones maxp with the outline statistics of the instanced glyphs:
|
||||||
|
// the max simple-glyph point and contour counts are recomputed, and the composite
|
||||||
|
// statistics are zeroed since the instance carries only simple glyphs. A
|
||||||
|
// version-0.5 maxp (too short to hold these fields) is returned unchanged.
|
||||||
|
func instanceMaxp(orig []byte, glyphs [][]byte) []byte {
|
||||||
|
maxp := append([]byte(nil), orig...)
|
||||||
|
if len(maxp) < 32 {
|
||||||
|
return maxp
|
||||||
|
}
|
||||||
|
maxPoints, maxContours := 0, 0
|
||||||
|
for _, g := range glyphs {
|
||||||
|
p, c := simpleGlyphStats(g)
|
||||||
|
maxPoints = max(maxPoints, p)
|
||||||
|
maxContours = max(maxContours, c)
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(maxp[6:], uint16(maxPoints))
|
||||||
|
binary.BigEndian.PutUint16(maxp[8:], uint16(maxContours))
|
||||||
|
binary.BigEndian.PutUint16(maxp[10:], 0) // maxCompositePoints
|
||||||
|
binary.BigEndian.PutUint16(maxp[12:], 0) // maxCompositeContours
|
||||||
|
return maxp
|
||||||
|
}
|
||||||
|
|
||||||
|
// simpleGlyphStats returns the point and contour counts of a re-encoded simple
|
||||||
|
// glyph blob (as encodeSimpleGlyph produced it), or (0,0) for an empty glyph.
|
||||||
|
func simpleGlyphStats(g []byte) (points, contours int) {
|
||||||
|
if len(g) < 10 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
n := int(int16(binary.BigEndian.Uint16(g)))
|
||||||
|
if n <= 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
// endPtsOfContours[n-1] + 1 is the point count; the last end point sits at
|
||||||
|
// offset 10 + 2*(n-1).
|
||||||
|
last := 10 + 2*(n-1)
|
||||||
|
if last+2 > len(g) {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return int(binary.BigEndian.Uint16(g[last:])) + 1, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// contourBounds returns the integer bounding box of the (rounded) contour points
|
||||||
|
// and whether any point was present.
|
||||||
|
func contourBounds(contours []contour) (xMin, yMin, xMax, yMax int, ok bool) {
|
||||||
|
for _, c := range contours {
|
||||||
|
for _, p := range c {
|
||||||
|
x, y := roundInt(p.x), roundInt(p.y)
|
||||||
|
if !ok {
|
||||||
|
xMin, yMin, xMax, yMax, ok = x, y, x, y, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xMin, yMin = min(xMin, x), min(yMin, y)
|
||||||
|
xMax, yMax = max(xMax, x), max(yMax, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xMin, yMin, xMax, yMax, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeSimpleGlyph re-encodes instanced contours as a TrueType simple glyph, with
|
||||||
|
// coordinates rounded to integers and no instructions. It returns nil (an empty
|
||||||
|
// glyph) when the contours hold no points. Off-curve (quadratic control) points
|
||||||
|
// are preserved, so the re-encoded glyph re-parses to the same outline.
|
||||||
|
func encodeSimpleGlyph(contours []contour) []byte {
|
||||||
|
var pts []outlinePoint
|
||||||
|
var ends []int
|
||||||
|
for _, c := range contours {
|
||||||
|
if len(c) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pts = append(pts, c...)
|
||||||
|
ends = append(ends, len(pts)-1)
|
||||||
|
}
|
||||||
|
if len(pts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
xMin, yMin, xMax, yMax, _ := contourBounds(contours)
|
||||||
|
|
||||||
|
w := &glyfWriter{}
|
||||||
|
w.i16(int16(len(ends)))
|
||||||
|
w.i16(int16(xMin))
|
||||||
|
w.i16(int16(yMin))
|
||||||
|
w.i16(int16(xMax))
|
||||||
|
w.i16(int16(yMax))
|
||||||
|
for _, e := range ends {
|
||||||
|
w.u16(uint16(e))
|
||||||
|
}
|
||||||
|
w.u16(0) // instructionLength
|
||||||
|
|
||||||
|
var flags, xs, ys []byte
|
||||||
|
px, py := 0, 0
|
||||||
|
for _, p := range pts {
|
||||||
|
x, y := roundInt(p.x), roundInt(p.y)
|
||||||
|
var fl byte
|
||||||
|
if p.on {
|
||||||
|
fl |= flagOnCurve
|
||||||
|
}
|
||||||
|
fx, xb := encodeGlyfDelta(x-px, flagXShort, flagXSamePosX)
|
||||||
|
fy, yb := encodeGlyfDelta(y-py, flagYShort, flagYSamePosY)
|
||||||
|
fl |= fx | fy
|
||||||
|
flags = append(flags, fl)
|
||||||
|
xs = append(xs, xb...)
|
||||||
|
ys = append(ys, yb...)
|
||||||
|
px, py = x, y
|
||||||
|
}
|
||||||
|
w.b = append(w.b, flags...)
|
||||||
|
w.b = append(w.b, xs...)
|
||||||
|
w.b = append(w.b, ys...)
|
||||||
|
return w.b
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeGlyfDelta encodes one coordinate delta with the short/same/long flag
|
||||||
|
// scheme readCoords decodes: zero deltas set the "same" bit and emit no bytes,
|
||||||
|
// deltas in [-255,255] use one byte with a sign carried by the short/positive
|
||||||
|
// bits, and larger deltas fall back to a signed 16-bit value.
|
||||||
|
func encodeGlyfDelta(delta int, shortBit, samePosBit byte) (flag byte, data []byte) {
|
||||||
|
switch {
|
||||||
|
case delta == 0:
|
||||||
|
return samePosBit, nil
|
||||||
|
case delta > 0 && delta <= 255:
|
||||||
|
return shortBit | samePosBit, []byte{byte(delta)}
|
||||||
|
case delta < 0 && delta >= -255:
|
||||||
|
return shortBit, []byte{byte(-delta)}
|
||||||
|
default:
|
||||||
|
d := int16(delta)
|
||||||
|
return 0, []byte{byte(uint16(d) >> 8), byte(d)}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file parses the legacy 'kern' table in its Microsoft/OpenType form
|
||||||
|
// (table version 0), decoding only format-0 subtables: a flat list of
|
||||||
|
// (left, right) glyph pairs each with a signed kerning value in font units.
|
||||||
|
// Non-format-0 subtables are skipped by their declared length. The Apple
|
||||||
|
// version-1 ('kern' with a 32-bit version) header is not handled.
|
||||||
|
|
||||||
|
// kern is a decoded legacy kern table: kerning values keyed by the packed
|
||||||
|
// (left, right) glyph pair.
|
||||||
|
type kern struct {
|
||||||
|
pairs map[uint32]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernKey packs a glyph pair into the map key.
|
||||||
|
func kernKey(left, right GlyphIndex) uint32 {
|
||||||
|
return uint32(left)<<16 | uint32(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKern decodes a legacy kern table (version 0).
|
||||||
|
func parseKern(b []byte) (*kern, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // version (0)
|
||||||
|
nTables := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: kern header: %w", r.err)
|
||||||
|
}
|
||||||
|
k := &kern{pairs: map[uint32]int{}}
|
||||||
|
for t := 0; t < nTables; t++ {
|
||||||
|
stStart := r.pos
|
||||||
|
r.skip(2) // subtable version
|
||||||
|
length := int(r.u16())
|
||||||
|
coverage := r.u16()
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: kern subtable header: %w", r.err)
|
||||||
|
}
|
||||||
|
if coverage>>8 == 0 { // format is the high byte of the coverage field
|
||||||
|
if err := k.parseFormat0(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Advance to the next subtable using its declared length. For format 0
|
||||||
|
// this matches where the sequential read left off; for skipped formats
|
||||||
|
// it steps over the unread body.
|
||||||
|
next := stStart + length
|
||||||
|
if next > len(r.b) {
|
||||||
|
return nil, fmt.Errorf("opentype: kern subtable length: %w", errTruncated)
|
||||||
|
}
|
||||||
|
r.pos = next
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormat0 reads a format-0 subtable body (positioned just after the 6-byte
|
||||||
|
// subtable header) into the pair map.
|
||||||
|
func (k *kern) parseFormat0(r *reader) error {
|
||||||
|
nPairs := int(r.u16())
|
||||||
|
r.skip(6) // searchRange, entrySelector, rangeShift
|
||||||
|
for i := 0; i < nPairs; i++ {
|
||||||
|
left := GlyphIndex(r.u16())
|
||||||
|
right := GlyphIndex(r.u16())
|
||||||
|
value := int(r.i16())
|
||||||
|
k.pairs[kernKey(left, right)] = value
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: kern format 0: %w", r.err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair returns the legacy kerning adjustment between left and right in font
|
||||||
|
// units, or 0 when the pair is absent.
|
||||||
|
func (k *kern) Pair(left, right GlyphIndex) int {
|
||||||
|
return k.pairs[kernKey(left, right)]
|
||||||
|
}
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file parses the structures shared by the OpenType Layout tables GSUB
|
||||||
|
// and GPOS: the ScriptList, FeatureList and LookupList, plus the Coverage and
|
||||||
|
// ClassDef tables their subtables point at. The lookup subtables themselves are
|
||||||
|
// table-specific and decoded in gsub.go and gpos.go.
|
||||||
|
//
|
||||||
|
// All offsets inside a Layout table are unsigned 16-bit values measured from
|
||||||
|
// the start of the structure that contains them, so parsing proceeds by
|
||||||
|
// re-slicing at each offset with subslice and recursing.
|
||||||
|
|
||||||
|
// subslice returns b[off:], or nil when off is past the end of b. A nil result
|
||||||
|
// makes any subsequent bounded read record errTruncated, so callers can defer
|
||||||
|
// the error check to a single site.
|
||||||
|
func subslice(b []byte, off int) []byte {
|
||||||
|
if off > len(b) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b[off:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// tag reads a 4-byte OpenType tag as a string, recording errTruncated when the
|
||||||
|
// four bytes are not available.
|
||||||
|
func (r *reader) tag() string {
|
||||||
|
if r.pos+4 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := string(r.b[r.pos : r.pos+4])
|
||||||
|
r.pos += 4
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// langSys is a decoded LangSys table: the (optional) required feature index and
|
||||||
|
// the list of feature indices enabled for a script/language.
|
||||||
|
type langSys struct {
|
||||||
|
required uint16 // 0xFFFF means "no required feature"
|
||||||
|
features []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// langSysRecord binds a 4-byte language tag to its LangSys table.
|
||||||
|
type langSysRecord struct {
|
||||||
|
tag string
|
||||||
|
ls *langSys
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptRecord is a decoded Script table: its default LangSys (may be nil) and
|
||||||
|
// any language-specific LangSys tables, keyed by tag.
|
||||||
|
type scriptRecord struct {
|
||||||
|
tag string
|
||||||
|
defaultLang *langSys
|
||||||
|
langs []langSysRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// featureRecord is a decoded Feature table: the lookup indices it activates.
|
||||||
|
type featureRecord struct {
|
||||||
|
tag string
|
||||||
|
lookups []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// layoutHeader holds the script and feature tables shared by a GSUB or GPOS
|
||||||
|
// table. It is embedded into gsub and gpos to provide selectLookups.
|
||||||
|
type layoutHeader struct {
|
||||||
|
scripts []scriptRecord
|
||||||
|
features []featureRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLayoutCommon decodes the version header of a GSUB/GPOS table and its
|
||||||
|
// ScriptList, FeatureList and LookupList, returning the shared header plus the
|
||||||
|
// raw bytes of each Lookup table (decoded by the caller per lookup type).
|
||||||
|
func parseLayoutCommon(data []byte) (layoutHeader, [][]byte, error) {
|
||||||
|
r := reader{b: data}
|
||||||
|
r.skip(4) // majorVersion, minorVersion (1.1's featureVariations is ignored)
|
||||||
|
scriptOff := int(r.u16())
|
||||||
|
featureOff := int(r.u16())
|
||||||
|
lookupOff := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return layoutHeader{}, nil, fmt.Errorf("opentype: layout header: %w", r.err)
|
||||||
|
}
|
||||||
|
scripts, err := parseScriptList(subslice(data, scriptOff))
|
||||||
|
if err != nil {
|
||||||
|
return layoutHeader{}, nil, err
|
||||||
|
}
|
||||||
|
features, err := parseFeatureList(subslice(data, featureOff))
|
||||||
|
if err != nil {
|
||||||
|
return layoutHeader{}, nil, err
|
||||||
|
}
|
||||||
|
lookups, err := parseLookupList(subslice(data, lookupOff))
|
||||||
|
if err != nil {
|
||||||
|
return layoutHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return layoutHeader{scripts: scripts, features: features}, lookups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScriptList decodes a ScriptList table.
|
||||||
|
func parseScriptList(b []byte) ([]scriptRecord, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
count := int(r.u16())
|
||||||
|
type rec struct {
|
||||||
|
tag string
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
recs := make([]rec, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
recs[i].tag = r.tag()
|
||||||
|
recs[i].off = int(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: scriptList: %w", r.err)
|
||||||
|
}
|
||||||
|
out := make([]scriptRecord, count)
|
||||||
|
for i, rc := range recs {
|
||||||
|
sc, err := parseScript(subslice(b, rc.off))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sc.tag = rc.tag
|
||||||
|
out[i] = sc
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScript decodes a Script table (its default and language LangSys tables).
|
||||||
|
func parseScript(b []byte) (scriptRecord, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
defOff := int(r.u16())
|
||||||
|
langCount := int(r.u16())
|
||||||
|
type lr struct {
|
||||||
|
tag string
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
lrs := make([]lr, langCount)
|
||||||
|
for i := 0; i < langCount; i++ {
|
||||||
|
lrs[i].tag = r.tag()
|
||||||
|
lrs[i].off = int(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return scriptRecord{}, fmt.Errorf("opentype: script: %w", r.err)
|
||||||
|
}
|
||||||
|
var sc scriptRecord
|
||||||
|
if defOff != 0 {
|
||||||
|
ls, err := parseLangSys(subslice(b, defOff))
|
||||||
|
if err != nil {
|
||||||
|
return scriptRecord{}, err
|
||||||
|
}
|
||||||
|
sc.defaultLang = ls
|
||||||
|
}
|
||||||
|
sc.langs = make([]langSysRecord, langCount)
|
||||||
|
for i, l := range lrs {
|
||||||
|
ls, err := parseLangSys(subslice(b, l.off))
|
||||||
|
if err != nil {
|
||||||
|
return scriptRecord{}, err
|
||||||
|
}
|
||||||
|
sc.langs[i] = langSysRecord{tag: l.tag, ls: ls}
|
||||||
|
}
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLangSys decodes a LangSys table.
|
||||||
|
func parseLangSys(b []byte) (*langSys, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // lookupOrderOffset (reserved, always 0)
|
||||||
|
required := r.u16()
|
||||||
|
n := int(r.u16())
|
||||||
|
feats := make([]uint16, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
feats[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: langSys: %w", r.err)
|
||||||
|
}
|
||||||
|
return &langSys{required: required, features: feats}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFeatureList decodes a FeatureList table.
|
||||||
|
func parseFeatureList(b []byte) ([]featureRecord, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
count := int(r.u16())
|
||||||
|
type rec struct {
|
||||||
|
tag string
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
recs := make([]rec, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
recs[i].tag = r.tag()
|
||||||
|
recs[i].off = int(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: featureList: %w", r.err)
|
||||||
|
}
|
||||||
|
out := make([]featureRecord, count)
|
||||||
|
for i, rc := range recs {
|
||||||
|
fr, err := parseFeature(subslice(b, rc.off))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fr.tag = rc.tag
|
||||||
|
out[i] = fr
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFeature decodes a Feature table (its lookup index list).
|
||||||
|
func parseFeature(b []byte) (featureRecord, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(2) // featureParamsOffset (unused here)
|
||||||
|
n := int(r.u16())
|
||||||
|
idx := make([]uint16, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
idx[i] = r.u16()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return featureRecord{}, fmt.Errorf("opentype: feature: %w", r.err)
|
||||||
|
}
|
||||||
|
return featureRecord{lookups: idx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLookupList decodes a LookupList table into the raw bytes of each Lookup
|
||||||
|
// table. Type-specific decoding happens in gsub.go and gpos.go.
|
||||||
|
func parseLookupList(b []byte) ([][]byte, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
count := int(r.u16())
|
||||||
|
offs := make([]int, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
offs[i] = int(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: lookupList: %w", r.err)
|
||||||
|
}
|
||||||
|
out := make([][]byte, count)
|
||||||
|
for i, off := range offs {
|
||||||
|
out[i] = subslice(b, off)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCoverage decodes a Coverage table (format 1 or 2) into a map from glyph
|
||||||
|
// to its coverage index.
|
||||||
|
func parseCoverage(b []byte) (map[GlyphIndex]int, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
format := r.u16()
|
||||||
|
m := map[GlyphIndex]int{}
|
||||||
|
switch format {
|
||||||
|
case 1:
|
||||||
|
n := int(r.u16())
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m[GlyphIndex(r.u16())] = i
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
n := int(r.u16())
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
start := int(r.u16())
|
||||||
|
end := int(r.u16())
|
||||||
|
startCov := int(r.u16())
|
||||||
|
for g := start; g <= end; g++ {
|
||||||
|
m[GlyphIndex(g)] = startCov + (g - start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: coverage format %d", format)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: coverage: %w", r.err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClassDef decodes a ClassDef table (format 1 or 2) into a map from glyph
|
||||||
|
// to class. Glyphs absent from the map belong to class 0.
|
||||||
|
func parseClassDef(b []byte) (map[GlyphIndex]int, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
format := r.u16()
|
||||||
|
m := map[GlyphIndex]int{}
|
||||||
|
switch format {
|
||||||
|
case 1:
|
||||||
|
start := int(r.u16())
|
||||||
|
n := int(r.u16())
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m[GlyphIndex(start+i)] = int(r.u16())
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
n := int(r.u16())
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
s := int(r.u16())
|
||||||
|
e := int(r.u16())
|
||||||
|
c := int(r.u16())
|
||||||
|
for g := s; g <= e; g++ {
|
||||||
|
m[GlyphIndex(g)] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: classdef format %d", format)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: classdef: %w", r.err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findScript resolves a script tag, falling back to "DFLT" and then to the
|
||||||
|
// first script present, matching the behaviour of common shapers.
|
||||||
|
func (h *layoutHeader) findScript(tag string) *scriptRecord {
|
||||||
|
for i := range h.scripts {
|
||||||
|
if h.scripts[i].tag == tag {
|
||||||
|
return &h.scripts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range h.scripts {
|
||||||
|
if h.scripts[i].tag == "DFLT" {
|
||||||
|
return &h.scripts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(h.scripts) > 0 {
|
||||||
|
return &h.scripts[0]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLang resolves a language tag within a script, falling back to the script's
|
||||||
|
// default LangSys.
|
||||||
|
func (s *scriptRecord) findLang(tag string) *langSys {
|
||||||
|
for i := range s.langs {
|
||||||
|
if s.langs[i].tag == tag {
|
||||||
|
return s.langs[i].ls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.defaultLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectLookups returns the lookup indices activated by the wanted feature tags
|
||||||
|
// for a (script, language) pair, in ascending lookup order and de-duplicated.
|
||||||
|
// The required feature (if any) is included when its tag is wanted.
|
||||||
|
func (h *layoutHeader) selectLookups(script, lang string, want map[string]bool) []int {
|
||||||
|
sr := h.findScript(script)
|
||||||
|
if sr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ls := sr.findLang(lang)
|
||||||
|
if ls == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var out []int
|
||||||
|
add := func(featIdx uint16) {
|
||||||
|
if int(featIdx) >= len(h.features) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fr := h.features[featIdx]
|
||||||
|
if !want[fr.tag] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, li := range fr.lookups {
|
||||||
|
if !seen[int(li)] {
|
||||||
|
seen[int(li)] = true
|
||||||
|
out = append(out, int(li))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ls.required != 0xFFFF {
|
||||||
|
add(ls.required)
|
||||||
|
}
|
||||||
|
for _, fi := range ls.features {
|
||||||
|
add(fi)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
+674
@@ -0,0 +1,674 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file decodes the OpenType MATH table: the font-level metrics a math
|
||||||
|
// typesetter needs. It is the layer LuaTeX, unicode-math, MathJax and Word's
|
||||||
|
// equation editor build their math layout on. The MATH table has three parts:
|
||||||
|
//
|
||||||
|
// - MathConstants: ~56 global scalars (subscript/superscript shifts, fraction
|
||||||
|
// and radical gaps, rule thicknesses, the math axis height, …).
|
||||||
|
// - MathGlyphInfo: per-glyph italic correction, top-accent attachment points,
|
||||||
|
// an "extended shape" set and cut-in math kerning for the four glyph corners.
|
||||||
|
// - MathVariants: larger size variants of a glyph plus the recipe (a
|
||||||
|
// GlyphAssembly of repeatable parts) for building an arbitrarily tall or wide
|
||||||
|
// stretchy glyph (integrals, braces, radicals, arrows).
|
||||||
|
//
|
||||||
|
// This package exposes those metrics, pixel-scaled through a Face. The actual
|
||||||
|
// math LAYOUT — box building, TeX's math list rules, choosing a variant or
|
||||||
|
// assembling a stretchy glyph at a target size — is a higher-level engine that
|
||||||
|
// consumes these values; it is out of scope here.
|
||||||
|
//
|
||||||
|
// All offsets inside the table are unsigned 16-bit values measured from the
|
||||||
|
// start of the structure that contains them, so parsing re-slices with subslice
|
||||||
|
// and recurses, exactly as gsub.go/gpos.go/layout.go do.
|
||||||
|
|
||||||
|
// MathConstant identifies one scalar in the MATH table's MathConstants record.
|
||||||
|
// The constants are listed in their on-disk order. Face.MathConstant returns
|
||||||
|
// each pixel-scaled, except the three percentage constants
|
||||||
|
// (ScriptPercentScaleDown, ScriptScriptPercentScaleDown and
|
||||||
|
// RadicalDegreeBottomRaisePercent), which are pure ratios and returned verbatim.
|
||||||
|
type MathConstant int
|
||||||
|
|
||||||
|
// The MathConstants record fields, in table order. mathConstCount is the count
|
||||||
|
// sentinel (not a real constant).
|
||||||
|
const (
|
||||||
|
ScriptPercentScaleDown MathConstant = iota
|
||||||
|
ScriptScriptPercentScaleDown
|
||||||
|
DelimitedSubFormulaMinHeight
|
||||||
|
DisplayOperatorMinHeight
|
||||||
|
MathLeading
|
||||||
|
AxisHeight
|
||||||
|
AccentBaseHeight
|
||||||
|
FlattenedAccentBaseHeight
|
||||||
|
SubscriptShiftDown
|
||||||
|
SubscriptTopMax
|
||||||
|
SubscriptBaselineDropMin
|
||||||
|
SuperscriptShiftUp
|
||||||
|
SuperscriptShiftUpCramped
|
||||||
|
SuperscriptBottomMin
|
||||||
|
SuperscriptBaselineDropMax
|
||||||
|
SubSuperscriptGapMin
|
||||||
|
SuperscriptBottomMaxWithSubscript
|
||||||
|
SpaceAfterScript
|
||||||
|
UpperLimitGapMin
|
||||||
|
UpperLimitBaselineRiseMin
|
||||||
|
LowerLimitGapMin
|
||||||
|
LowerLimitBaselineDropMin
|
||||||
|
StackTopShiftUp
|
||||||
|
StackTopDisplayStyleShiftUp
|
||||||
|
StackBottomShiftDown
|
||||||
|
StackBottomDisplayStyleShiftDown
|
||||||
|
StackGapMin
|
||||||
|
StackDisplayStyleGapMin
|
||||||
|
StretchStackTopShiftUp
|
||||||
|
StretchStackBottomShiftDown
|
||||||
|
StretchStackGapAboveMin
|
||||||
|
StretchStackGapBelowMin
|
||||||
|
FractionNumeratorShiftUp
|
||||||
|
FractionNumeratorDisplayStyleShiftUp
|
||||||
|
FractionDenominatorShiftDown
|
||||||
|
FractionDenominatorDisplayStyleShiftDown
|
||||||
|
FractionNumeratorGapMin
|
||||||
|
FractionNumDisplayStyleGapMin
|
||||||
|
FractionRuleThickness
|
||||||
|
FractionDenominatorGapMin
|
||||||
|
FractionDenomDisplayStyleGapMin
|
||||||
|
SkewedFractionHorizontalGap
|
||||||
|
SkewedFractionVerticalGap
|
||||||
|
OverbarVerticalGap
|
||||||
|
OverbarRuleThickness
|
||||||
|
OverbarExtraAscender
|
||||||
|
UnderbarVerticalGap
|
||||||
|
UnderbarRuleThickness
|
||||||
|
UnderbarExtraDescender
|
||||||
|
RadicalVerticalGap
|
||||||
|
RadicalDisplayStyleVerticalGap
|
||||||
|
RadicalRuleThickness
|
||||||
|
RadicalExtraAscender
|
||||||
|
RadicalKernBeforeDegree
|
||||||
|
RadicalKernAfterDegree
|
||||||
|
RadicalDegreeBottomRaisePercent
|
||||||
|
|
||||||
|
mathConstCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// isPercent reports whether c is a percentage ratio rather than a design-unit
|
||||||
|
// length; such a constant is returned unscaled.
|
||||||
|
func (c MathConstant) isPercent() bool {
|
||||||
|
return c == ScriptPercentScaleDown ||
|
||||||
|
c == ScriptScriptPercentScaleDown ||
|
||||||
|
c == RadicalDegreeBottomRaisePercent
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathKernCorner selects one of a glyph's four math-kern corners, used for the
|
||||||
|
// staircase cut-in between a base glyph and an adjacent script.
|
||||||
|
type MathKernCorner int
|
||||||
|
|
||||||
|
// The four math-kern corners, in the on-disk order of a MathKernInfoRecord.
|
||||||
|
const (
|
||||||
|
MathKernTopRight MathKernCorner = iota
|
||||||
|
MathKernTopLeft
|
||||||
|
MathKernBottomRight
|
||||||
|
MathKernBottomLeft
|
||||||
|
)
|
||||||
|
|
||||||
|
// partFlagExtender marks a GlyphPart that may be repeated to lengthen a stretchy
|
||||||
|
// glyph assembly (PART_FLAG_EXTENDER).
|
||||||
|
const partFlagExtender = 0x0001
|
||||||
|
|
||||||
|
// MathVariant is one size variant of a stretchy glyph: the variant's glyph and
|
||||||
|
// its advance (height for a vertical variant, width for a horizontal one) in
|
||||||
|
// pixels at the Face's size.
|
||||||
|
type MathVariant struct {
|
||||||
|
Glyph GlyphIndex
|
||||||
|
Advance int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathAssembly is the recipe for building a stretchy glyph out of repeatable
|
||||||
|
// parts, with all measurements in pixels at the Face's size. A layout engine
|
||||||
|
// stacks the parts (vertically or horizontally), overlapping neighbours by at
|
||||||
|
// least MinConnectorOverlap and repeating the Extender parts until the assembly
|
||||||
|
// reaches the target size.
|
||||||
|
type MathAssembly struct {
|
||||||
|
ItalicsCorrection int
|
||||||
|
MinConnectorOverlap int
|
||||||
|
Parts []MathAssemblyPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathAssemblyPart is one component of a MathAssembly. StartConnector and
|
||||||
|
// EndConnector are the maximum overlap lengths at the part's leading and
|
||||||
|
// trailing ends; FullAdvance is the part's full extent along the stretch axis.
|
||||||
|
type MathAssemblyPart struct {
|
||||||
|
Glyph GlyphIndex
|
||||||
|
StartConnector int
|
||||||
|
EndConnector int
|
||||||
|
FullAdvance int
|
||||||
|
Extender bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathTable is a decoded MATH table. Every sub-part is optional; a font may
|
||||||
|
// carry constants only, glyph info only, variants only, or any combination.
|
||||||
|
type mathTable struct {
|
||||||
|
constants [mathConstCount]int32 // design-unit or percent scalars, indexed by MathConstant
|
||||||
|
hasConstants bool
|
||||||
|
|
||||||
|
italicCorr map[GlyphIndex]int32 // MathItalicsCorrectionInfo: per-glyph italic correction
|
||||||
|
topAccent map[GlyphIndex]int32 // MathTopAccentAttachment: per-glyph top-accent x
|
||||||
|
extendedShape map[GlyphIndex]int // ExtendedShapeCoverage, used as a set
|
||||||
|
kern map[GlyphIndex]*mathKernRecord
|
||||||
|
|
||||||
|
minConnectorOverlap int32
|
||||||
|
vertConstruct map[GlyphIndex]*mathGlyphConstruction // vertical (tall) stretch
|
||||||
|
horizConstruct map[GlyphIndex]*mathGlyphConstruction // horizontal (wide) stretch
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathKernRecord holds a glyph's four corner MathKerns (any may be nil).
|
||||||
|
type mathKernRecord struct {
|
||||||
|
corners [4]*mathKern
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathKern is a step function of correction height: heights has n entries and
|
||||||
|
// kerns has n+1, so a height selects the kern in the interval it falls into.
|
||||||
|
type mathKern struct {
|
||||||
|
heights []int32 // ascending correctionHeight values (design units)
|
||||||
|
kerns []int32 // kern values (design units), one more than heights
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueAt returns the kern for correction height h (design units) by locating
|
||||||
|
// the interval h falls into: the first heights[i] strictly greater than h, or
|
||||||
|
// the final kern when h is at or above every height.
|
||||||
|
func (k *mathKern) valueAt(h int32) int32 {
|
||||||
|
i := sort.Search(len(k.heights), func(i int) bool { return h < k.heights[i] })
|
||||||
|
return k.kerns[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathGlyphConstruction lists a glyph's size variants and, optionally, the
|
||||||
|
// assembly recipe for an arbitrarily stretched glyph.
|
||||||
|
type mathGlyphConstruction struct {
|
||||||
|
variants []mathVariantRecord
|
||||||
|
assembly *mathAssemblyData
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathVariantRecord is one MathGlyphVariantRecord (design units).
|
||||||
|
type mathVariantRecord struct {
|
||||||
|
glyph GlyphIndex
|
||||||
|
advance int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathAssemblyData is a decoded GlyphAssembly (design units).
|
||||||
|
type mathAssemblyData struct {
|
||||||
|
italicsCorrection int32
|
||||||
|
parts []mathPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathPart is one GlyphPart of an assembly (design units).
|
||||||
|
type mathPart struct {
|
||||||
|
glyph GlyphIndex
|
||||||
|
startConnector int32
|
||||||
|
endConnector int32
|
||||||
|
fullAdvance int32
|
||||||
|
extender bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// mathValue reads a MathValueRecord: an int16 design-unit value followed by an
|
||||||
|
// Offset16 to an optional Device table, which this package tolerates and
|
||||||
|
// ignores (device deltas fine-tune a value at a specific ppem; a scalable
|
||||||
|
// renderer does not need them).
|
||||||
|
func (r *reader) mathValue() int32 {
|
||||||
|
v := r.i16()
|
||||||
|
r.skip(2) // deviceOffset, ignored
|
||||||
|
return int32(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMath decodes the optional MATH table and stores it on the font. Absence
|
||||||
|
// is not an error; a malformed table fails the whole parse so a corrupt font is
|
||||||
|
// rejected cleanly, matching the other layout tables.
|
||||||
|
func (f *Font) parseMath(tables map[string][]byte) error {
|
||||||
|
b, ok := tables["MATH"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, err := parseMATH(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.math = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMATH decodes a MATH table: a version header followed by up to three
|
||||||
|
// independent sub-offsets.
|
||||||
|
func parseMATH(b []byte) (*mathTable, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.skip(4) // majorVersion, minorVersion
|
||||||
|
constOff := int(r.u16())
|
||||||
|
glyphInfoOff := int(r.u16())
|
||||||
|
variantsOff := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH header: %w", r.err)
|
||||||
|
}
|
||||||
|
m := &mathTable{}
|
||||||
|
if constOff != 0 {
|
||||||
|
if err := m.parseConstants(subslice(b, constOff)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if glyphInfoOff != 0 {
|
||||||
|
if err := m.parseGlyphInfo(subslice(b, glyphInfoOff)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if variantsOff != 0 {
|
||||||
|
if err := m.parseVariants(subslice(b, variantsOff)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConstants decodes the MathConstants record into the constants array. The
|
||||||
|
// layout is two int16 percentages, two UFWORD minimum heights, 51 MathValue
|
||||||
|
// records and a trailing int16 percentage.
|
||||||
|
func (m *mathTable) parseConstants(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
var c [mathConstCount]int32
|
||||||
|
c[ScriptPercentScaleDown] = int32(r.i16())
|
||||||
|
c[ScriptScriptPercentScaleDown] = int32(r.i16())
|
||||||
|
c[DelimitedSubFormulaMinHeight] = int32(r.u16())
|
||||||
|
c[DisplayOperatorMinHeight] = int32(r.u16())
|
||||||
|
for i := MathLeading; i <= RadicalKernAfterDegree; i++ {
|
||||||
|
c[i] = r.mathValue()
|
||||||
|
}
|
||||||
|
c[RadicalDegreeBottomRaisePercent] = int32(r.i16())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: MATH constants: %w", r.err)
|
||||||
|
}
|
||||||
|
m.constants = c
|
||||||
|
m.hasConstants = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGlyphInfo decodes the MathGlyphInfo table: four optional sub-offsets.
|
||||||
|
func (m *mathTable) parseGlyphInfo(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
italicOff := int(r.u16())
|
||||||
|
topAccentOff := int(r.u16())
|
||||||
|
extShapeOff := int(r.u16())
|
||||||
|
kernOff := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: MATH glyphInfo: %w", r.err)
|
||||||
|
}
|
||||||
|
if italicOff != 0 {
|
||||||
|
vals, err := parsePerGlyphValues(subslice(b, italicOff))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.italicCorr = vals
|
||||||
|
}
|
||||||
|
if topAccentOff != 0 {
|
||||||
|
vals, err := parsePerGlyphValues(subslice(b, topAccentOff))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.topAccent = vals
|
||||||
|
}
|
||||||
|
if extShapeOff != 0 {
|
||||||
|
cov, err := parseCoverage(subslice(b, extShapeOff))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.extendedShape = cov
|
||||||
|
}
|
||||||
|
if kernOff != 0 {
|
||||||
|
k, err := parseMathKernInfo(subslice(b, kernOff))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.kern = k
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePerGlyphValues decodes the shape shared by MathItalicsCorrectionInfo and
|
||||||
|
// MathTopAccentAttachment: a Coverage offset, a count and that many MathValue
|
||||||
|
// records, joined into a per-glyph map.
|
||||||
|
func parsePerGlyphValues(b []byte) (map[GlyphIndex]int32, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
covOff := int(r.u16())
|
||||||
|
count := int(r.u16())
|
||||||
|
vals := make([]int32, count)
|
||||||
|
for i := range vals {
|
||||||
|
vals[i] = r.mathValue()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH per-glyph values: %w", r.err)
|
||||||
|
}
|
||||||
|
cov, err := parseCoverage(subslice(b, covOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return coverageValues(cov, vals), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverageValues joins a Coverage (glyph→index) with a parallel value slice into
|
||||||
|
// a glyph→value map.
|
||||||
|
func coverageValues(cov map[GlyphIndex]int, vals []int32) map[GlyphIndex]int32 {
|
||||||
|
out := make(map[GlyphIndex]int32, len(cov))
|
||||||
|
for g, i := range cov {
|
||||||
|
if i < len(vals) {
|
||||||
|
out[g] = vals[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMathKernInfo decodes the MathKernInfo table: a Coverage plus one
|
||||||
|
// MathKernInfoRecord (four corner offsets) per covered glyph.
|
||||||
|
func parseMathKernInfo(b []byte) (map[GlyphIndex]*mathKernRecord, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
covOff := int(r.u16())
|
||||||
|
count := int(r.u16())
|
||||||
|
offs := make([][4]int, count)
|
||||||
|
for i := range offs {
|
||||||
|
for c := 0; c < 4; c++ {
|
||||||
|
offs[i][c] = int(r.u16())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH kernInfo: %w", r.err)
|
||||||
|
}
|
||||||
|
recs := make([]*mathKernRecord, count)
|
||||||
|
for i := range offs {
|
||||||
|
rec := &mathKernRecord{}
|
||||||
|
for c := 0; c < 4; c++ {
|
||||||
|
if offs[i][c] == 0 {
|
||||||
|
continue // this corner has no math kern
|
||||||
|
}
|
||||||
|
k, err := parseMathKern(subslice(b, offs[i][c]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rec.corners[c] = k
|
||||||
|
}
|
||||||
|
recs[i] = rec
|
||||||
|
}
|
||||||
|
cov, err := parseCoverage(subslice(b, covOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[GlyphIndex]*mathKernRecord, len(cov))
|
||||||
|
for g, i := range cov {
|
||||||
|
if i < len(recs) {
|
||||||
|
out[g] = recs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMathKern decodes a MathKern subtable: heightCount correction heights and
|
||||||
|
// heightCount+1 kern values.
|
||||||
|
func parseMathKern(b []byte) (*mathKern, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
n := int(r.u16())
|
||||||
|
heights := make([]int32, n)
|
||||||
|
for i := range heights {
|
||||||
|
heights[i] = r.mathValue()
|
||||||
|
}
|
||||||
|
kerns := make([]int32, n+1)
|
||||||
|
for i := range kerns {
|
||||||
|
kerns[i] = r.mathValue()
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH kern: %w", r.err)
|
||||||
|
}
|
||||||
|
return &mathKern{heights: heights, kerns: kerns}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVariants decodes the MathVariants table: the minimum connector overlap,
|
||||||
|
// two Coverage offsets and the vertical and horizontal glyph constructions.
|
||||||
|
func (m *mathTable) parseVariants(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
m.minConnectorOverlap = int32(r.u16())
|
||||||
|
vertCovOff := int(r.u16())
|
||||||
|
horizCovOff := int(r.u16())
|
||||||
|
vertCount := int(r.u16())
|
||||||
|
horizCount := int(r.u16())
|
||||||
|
vertOffs := make([]int, vertCount)
|
||||||
|
for i := range vertOffs {
|
||||||
|
vertOffs[i] = int(r.u16())
|
||||||
|
}
|
||||||
|
horizOffs := make([]int, horizCount)
|
||||||
|
for i := range horizOffs {
|
||||||
|
horizOffs[i] = int(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: MATH variants: %w", r.err)
|
||||||
|
}
|
||||||
|
vert, err := buildConstructions(b, vertCovOff, vertOffs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
horiz, err := buildConstructions(b, horizCovOff, horizOffs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.vertConstruct = vert
|
||||||
|
m.horizConstruct = horiz
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConstructions decodes each MathGlyphConstruction at offs and keys them by
|
||||||
|
// the glyphs in the Coverage at covOff. A zero covOff (an empty axis) yields an
|
||||||
|
// empty map without touching the coverage bytes.
|
||||||
|
func buildConstructions(b []byte, covOff int, offs []int) (map[GlyphIndex]*mathGlyphConstruction, error) {
|
||||||
|
out := map[GlyphIndex]*mathGlyphConstruction{}
|
||||||
|
if covOff == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
constructs := make([]*mathGlyphConstruction, len(offs))
|
||||||
|
for i, off := range offs {
|
||||||
|
gc, err := parseGlyphConstruction(subslice(b, off))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
constructs[i] = gc
|
||||||
|
}
|
||||||
|
cov, err := parseCoverage(subslice(b, covOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for g, i := range cov {
|
||||||
|
if i < len(constructs) {
|
||||||
|
out[g] = constructs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGlyphConstruction decodes a MathGlyphConstruction: an optional
|
||||||
|
// GlyphAssembly offset and a list of MathGlyphVariantRecords.
|
||||||
|
func parseGlyphConstruction(b []byte) (*mathGlyphConstruction, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
asmOff := int(r.u16())
|
||||||
|
variantCount := int(r.u16())
|
||||||
|
variants := make([]mathVariantRecord, variantCount)
|
||||||
|
for i := range variants {
|
||||||
|
variants[i].glyph = GlyphIndex(r.u16())
|
||||||
|
variants[i].advance = int32(r.u16())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH glyph construction: %w", r.err)
|
||||||
|
}
|
||||||
|
gc := &mathGlyphConstruction{variants: variants}
|
||||||
|
if asmOff != 0 {
|
||||||
|
asm, err := parseGlyphAssembly(subslice(b, asmOff))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gc.assembly = asm
|
||||||
|
}
|
||||||
|
return gc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGlyphAssembly decodes a GlyphAssembly: an italics-correction MathValue
|
||||||
|
// and a list of GlyphParts.
|
||||||
|
func parseGlyphAssembly(b []byte) (*mathAssemblyData, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
italic := r.mathValue()
|
||||||
|
partCount := int(r.u16())
|
||||||
|
parts := make([]mathPart, partCount)
|
||||||
|
for i := range parts {
|
||||||
|
parts[i].glyph = GlyphIndex(r.u16())
|
||||||
|
parts[i].startConnector = int32(r.u16())
|
||||||
|
parts[i].endConnector = int32(r.u16())
|
||||||
|
parts[i].fullAdvance = int32(r.u16())
|
||||||
|
parts[i].extender = r.u16()&partFlagExtender != 0
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: MATH glyph assembly: %w", r.err)
|
||||||
|
}
|
||||||
|
return &mathAssemblyData{italicsCorrection: italic, parts: parts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- public API -------------------------------------------------------------
|
||||||
|
|
||||||
|
// HasMath reports whether the font carries an OpenType MATH table (the metrics
|
||||||
|
// a math-typesetting engine needs). When false, all Face math accessors return
|
||||||
|
// their zero result.
|
||||||
|
func (f *Font) HasMath() bool { return f.math != nil }
|
||||||
|
|
||||||
|
// IsExtendedShapeGlyph reports whether gid is in the MATH table's extended-shape
|
||||||
|
// coverage: a tall glyph (such as a large integral) whose superscript is
|
||||||
|
// positioned as if the glyph were a stretched, rather than a fixed-size, shape.
|
||||||
|
func (f *Font) IsExtendedShapeGlyph(gid GlyphIndex) bool {
|
||||||
|
if f.math == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := f.math.extendedShape[gid]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleUnit converts a design-unit value to whole pixels at the face's size.
|
||||||
|
func (fc *Face) scaleUnit(v int32) int { return roundInt(float64(v) * fc.scale) }
|
||||||
|
|
||||||
|
// MathConstant returns the MathConstants value which, pixel-scaled at the face's
|
||||||
|
// size — except the three percentage constants, which are pure ratios and
|
||||||
|
// returned verbatim. It returns 0 when the font has no MATH constants or which
|
||||||
|
// is out of range.
|
||||||
|
func (fc *Face) MathConstant(which MathConstant) int {
|
||||||
|
m := fc.font.math
|
||||||
|
if m == nil || !m.hasConstants || which < 0 || which >= mathConstCount {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := m.constants[which]
|
||||||
|
if which.isPercent() {
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
return fc.scaleUnit(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItalicCorrection returns glyph gid's math italic correction in pixels, or 0
|
||||||
|
// when the glyph has none (or the font carries no MATH table). It is the space
|
||||||
|
// to insert after a slanted glyph before a following upright one, and the
|
||||||
|
// horizontal offset for a subscript that follows a superscript.
|
||||||
|
func (fc *Face) ItalicCorrection(gid GlyphIndex) int {
|
||||||
|
m := fc.font.math
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fc.scaleUnit(m.italicCorr[gid])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopAccentAttachment returns the x position (in pixels from the glyph origin)
|
||||||
|
// at which a top accent should be centred over glyph gid. ok is false when the
|
||||||
|
// glyph has no entry, in which case a layout engine centres on the glyph's
|
||||||
|
// advance instead.
|
||||||
|
func (fc *Face) TopAccentAttachment(gid GlyphIndex) (int, bool) {
|
||||||
|
m := fc.font.math
|
||||||
|
if m == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, ok := m.topAccent[gid]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return fc.scaleUnit(v), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathKern returns the math cut-in kern (in pixels) for glyph gid at the given
|
||||||
|
// corner and correction height, the height (in pixels above the baseline) at
|
||||||
|
// which the adjacent script attaches. It returns 0 when the glyph has no kern
|
||||||
|
// for that corner or the font carries no MATH table.
|
||||||
|
func (fc *Face) MathKern(gid GlyphIndex, corner MathKernCorner, correctionHeight int) int {
|
||||||
|
m := fc.font.math
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rec := m.kern[gid]
|
||||||
|
if rec == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if corner < 0 || corner > MathKernBottomLeft {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
k := rec.corners[corner]
|
||||||
|
if k == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
hUnits := int32(roundInt(float64(correctionHeight) / fc.scale))
|
||||||
|
return fc.scaleUnit(k.valueAt(hUnits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathVariants returns the size variants and the stretchy-glyph assembly for
|
||||||
|
// glyph gid along the requested axis (vertical when vertical is true, otherwise
|
||||||
|
// horizontal), with every measurement pixel-scaled at the face's size. Variants
|
||||||
|
// are ordered smallest-first as stored in the font; the assembly is nil when the
|
||||||
|
// glyph has no assembly recipe. Both results are nil when the glyph has no
|
||||||
|
// construction on that axis (or the font carries no MATH table). A math layout
|
||||||
|
// engine picks the first variant whose advance meets the target size, or builds
|
||||||
|
// the assembly when even the largest variant is too small.
|
||||||
|
func (fc *Face) MathVariants(gid GlyphIndex, vertical bool) ([]MathVariant, *MathAssembly) {
|
||||||
|
m := fc.font.math
|
||||||
|
if m == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
table := m.horizConstruct
|
||||||
|
if vertical {
|
||||||
|
table = m.vertConstruct
|
||||||
|
}
|
||||||
|
gc := table[gid]
|
||||||
|
if gc == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var variants []MathVariant
|
||||||
|
for _, v := range gc.variants {
|
||||||
|
variants = append(variants, MathVariant{Glyph: v.glyph, Advance: fc.scaleUnit(v.advance)})
|
||||||
|
}
|
||||||
|
var asm *MathAssembly
|
||||||
|
if gc.assembly != nil {
|
||||||
|
a := gc.assembly
|
||||||
|
asm = &MathAssembly{
|
||||||
|
ItalicsCorrection: fc.scaleUnit(a.italicsCorrection),
|
||||||
|
MinConnectorOverlap: fc.scaleUnit(m.minConnectorOverlap),
|
||||||
|
Parts: make([]MathAssemblyPart, len(a.parts)),
|
||||||
|
}
|
||||||
|
for i, p := range a.parts {
|
||||||
|
asm.Parts[i] = MathAssemblyPart{
|
||||||
|
Glyph: p.glyph,
|
||||||
|
StartConnector: fc.scaleUnit(p.startConnector),
|
||||||
|
EndConnector: fc.scaleUnit(p.endConnector),
|
||||||
|
FullAdvance: fc.scaleUnit(p.fullAdvance),
|
||||||
|
Extender: p.extender,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return variants, asm
|
||||||
|
}
|
||||||
+465
@@ -0,0 +1,465 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file implements the optional metric-variation tables HVAR, VVAR and
|
||||||
|
// MVAR, so a variable font's advance widths, advance heights and global font
|
||||||
|
// metrics track the axis coordinate rather than staying at the default master.
|
||||||
|
//
|
||||||
|
// - HVAR (Horizontal Metrics Variations): per-glyph horizontal-advance deltas
|
||||||
|
// (plus optional left/right side-bearing maps), applied by Face.Advance,
|
||||||
|
// Face.Measure and the GlyphMask family.
|
||||||
|
// - VVAR (Vertical Metrics Variations): the vertical analogue, per-glyph
|
||||||
|
// advance-height deltas, applied by Face.VerticalAdvance.
|
||||||
|
// - MVAR (Metrics Variations): global font-metric deltas keyed by four-byte
|
||||||
|
// value tags (hasc/hdsc/hlgp/...), folded into Face.Metrics.
|
||||||
|
//
|
||||||
|
// All three share one machinery: an ItemVariationStore (the same format 1 store
|
||||||
|
// CFF2 references, but here the delta sets are fully decoded because the deltas
|
||||||
|
// come from the store rather than from a charstring) plus, per metric, an
|
||||||
|
// optional DeltaSetIndexMap translating a glyph index into an (outer, inner)
|
||||||
|
// pair addressing the store. The tent-function region evaluator supportScalar
|
||||||
|
// (gvar.go) is reused unchanged.
|
||||||
|
//
|
||||||
|
// When a variable glyf font ships no HVAR table, horizontal advances fall back
|
||||||
|
// to the per-glyph gvar phantom-point deltas the specification prescribes (see
|
||||||
|
// gvarAdvanceWidthDelta); this fallback is implemented for simple glyphs.
|
||||||
|
// Composite-glyph advance fallback is out of scope (it returns no delta).
|
||||||
|
|
||||||
|
// metricVarData is one ItemVariationData subtable: the ordered region indices
|
||||||
|
// its columns correspond to, and one fully-decoded delta row per item.
|
||||||
|
type metricVarData struct {
|
||||||
|
regionIndices []int
|
||||||
|
deltaSets [][]int32 // itemCount rows, each len(regionIndices) deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricVarStore is a format-1 ItemVariationStore with its delta sets decoded.
|
||||||
|
// Each region is kept as parallel per-axis lower/peak/upper coordinate slices so
|
||||||
|
// the shared supportScalar evaluator can be called without repacking.
|
||||||
|
type metricVarStore struct {
|
||||||
|
axisCount int
|
||||||
|
regLower [][]float64
|
||||||
|
regPeak [][]float64
|
||||||
|
regUpper [][]float64
|
||||||
|
data []metricVarData
|
||||||
|
}
|
||||||
|
|
||||||
|
// delta returns the interpolated delta for item (outer, inner) at the normalized
|
||||||
|
// coordinate norm (F2Dot14 int16 per axis, as produced by NormalizeCoords): the
|
||||||
|
// sum over the subtable's regions of the region's tent scalar times the stored
|
||||||
|
// delta. An out-of-range outer or inner index yields zero.
|
||||||
|
func (s *metricVarStore) delta(outer, inner int, norm []int16) float64 {
|
||||||
|
if outer < 0 || outer >= len(s.data) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := s.data[outer]
|
||||||
|
if inner < 0 || inner >= len(d.deltaSets) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
coords := make([]float64, s.axisCount)
|
||||||
|
for i := range coords {
|
||||||
|
if i < len(norm) {
|
||||||
|
coords[i] = float64(norm[i]) / 16384.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row := d.deltaSets[inner]
|
||||||
|
sum := 0.0
|
||||||
|
for j, ri := range d.regionIndices {
|
||||||
|
sum += supportScalar(coords, s.regLower[ri], s.regPeak[ri], s.regUpper[ri]) * float64(row[j])
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetricVarStore decodes the ItemVariationStore at offset off in b, whose
|
||||||
|
// internal region-list and subtable offsets are relative to off. Only format 1
|
||||||
|
// is supported (the sole format defined for OpenType variations).
|
||||||
|
func parseMetricVarStore(b []byte, off int) (*metricVarStore, error) {
|
||||||
|
r := reader{b: b, pos: off}
|
||||||
|
if format := r.u16(); format != 1 {
|
||||||
|
return nil, fmt.Errorf("opentype: ItemVariationStore: unsupported format %d", format)
|
||||||
|
}
|
||||||
|
regionListOff := off + int(r.u32())
|
||||||
|
ivdCount := int(r.u16())
|
||||||
|
ivdOffsets := make([]int, ivdCount)
|
||||||
|
for i := range ivdOffsets {
|
||||||
|
ivdOffsets[i] = off + int(r.u32())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: ItemVariationStore header: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := reader{b: b, pos: regionListOff}
|
||||||
|
axisCount := int(rr.u16())
|
||||||
|
regionCount := int(rr.u16())
|
||||||
|
lower := make([][]float64, regionCount)
|
||||||
|
peak := make([][]float64, regionCount)
|
||||||
|
upper := make([][]float64, regionCount)
|
||||||
|
for i := 0; i < regionCount; i++ {
|
||||||
|
lo := make([]float64, axisCount)
|
||||||
|
pk := make([]float64, axisCount)
|
||||||
|
up := make([]float64, axisCount)
|
||||||
|
for a := 0; a < axisCount; a++ {
|
||||||
|
lo[a] = rr.f2dot14()
|
||||||
|
pk[a] = rr.f2dot14()
|
||||||
|
up[a] = rr.f2dot14()
|
||||||
|
}
|
||||||
|
lower[i], peak[i], upper[i] = lo, pk, up
|
||||||
|
}
|
||||||
|
if rr.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: ItemVariationStore regions: %w", rr.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]metricVarData, ivdCount)
|
||||||
|
for i, o := range ivdOffsets {
|
||||||
|
d, err := parseItemVarData(b, o, regionCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data[i] = d
|
||||||
|
}
|
||||||
|
return &metricVarStore{axisCount: axisCount, regLower: lower, regPeak: peak, regUpper: upper, data: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseItemVarData decodes one ItemVariationData subtable at offset off, reading
|
||||||
|
// its region ordering and the full grid of item deltas. regionCount bounds the
|
||||||
|
// region indices. The wordDeltaCount field's high bit (LONG_WORDS) and its low
|
||||||
|
// 15 bits (the count of wide leading columns) select each delta's byte width.
|
||||||
|
func parseItemVarData(b []byte, off, regionCount int) (metricVarData, error) {
|
||||||
|
r := reader{b: b, pos: off}
|
||||||
|
itemCount := int(r.u16())
|
||||||
|
wordDeltaCount := int(r.u16())
|
||||||
|
regionIndexCount := int(r.u16())
|
||||||
|
idx := make([]int, regionIndexCount)
|
||||||
|
for k := range idx {
|
||||||
|
ri := int(r.u16())
|
||||||
|
if ri >= regionCount {
|
||||||
|
return metricVarData{}, fmt.Errorf("opentype: ItemVariationData: region index %d out of range", ri)
|
||||||
|
}
|
||||||
|
idx[k] = ri
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return metricVarData{}, fmt.Errorf("opentype: ItemVariationData header: %w", r.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
longWords := wordDeltaCount&0x8000 != 0
|
||||||
|
wordCount := wordDeltaCount & 0x7FFF
|
||||||
|
if wordCount > regionIndexCount {
|
||||||
|
return metricVarData{}, fmt.Errorf("opentype: ItemVariationData: word count %d exceeds region count %d", wordCount, regionIndexCount)
|
||||||
|
}
|
||||||
|
sets := make([][]int32, itemCount)
|
||||||
|
for i := 0; i < itemCount; i++ {
|
||||||
|
row := make([]int32, regionIndexCount)
|
||||||
|
for j := 0; j < regionIndexCount; j++ {
|
||||||
|
row[j] = readVarDelta(&r, longWords, j < wordCount)
|
||||||
|
}
|
||||||
|
sets[i] = row
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return metricVarData{}, fmt.Errorf("opentype: ItemVariationData deltas: %w", r.err)
|
||||||
|
}
|
||||||
|
return metricVarData{regionIndices: idx, deltaSets: sets}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readVarDelta reads one ItemVariationData delta. With LONG_WORDS the wide
|
||||||
|
// columns are 32-bit and the narrow ones 16-bit; without it the wide columns are
|
||||||
|
// 16-bit and the narrow ones 8-bit. wide selects the wide width for this column.
|
||||||
|
func readVarDelta(r *reader, longWords, wide bool) int32 {
|
||||||
|
if longWords && wide {
|
||||||
|
return int32(r.u32())
|
||||||
|
}
|
||||||
|
if longWords || wide {
|
||||||
|
return int32(r.i16())
|
||||||
|
}
|
||||||
|
return int32(int8(r.u8()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltaSetMap is a decoded DeltaSetIndexMap: per glyph index, the (outer, inner)
|
||||||
|
// pair addressing the ItemVariationStore. A nil or empty map maps a glyph index
|
||||||
|
// to itself (outer 0, inner = glyph index).
|
||||||
|
type deltaSetMap struct {
|
||||||
|
outer []uint32
|
||||||
|
inner []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the (outer, inner) store indices for glyph gid. A nil or empty
|
||||||
|
// map is the identity mapping (outer 0, inner gid); a gid past the map's last
|
||||||
|
// entry clamps to that entry (as the specification prescribes).
|
||||||
|
func (m *deltaSetMap) lookup(gid int) (int, int) {
|
||||||
|
if m == nil || len(m.outer) == 0 {
|
||||||
|
return 0, gid
|
||||||
|
}
|
||||||
|
if gid >= len(m.outer) {
|
||||||
|
gid = len(m.outer) - 1
|
||||||
|
}
|
||||||
|
return int(m.outer[gid]), int(m.inner[gid])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeltaSetMap decodes the DeltaSetIndexMap at offset off in b (format 0 or
|
||||||
|
// 1). A zero offset means the map is absent and yields a nil map (the identity
|
||||||
|
// mapping). entryFormat's low nibble gives the inner-index bit count minus one
|
||||||
|
// and bits 4-5 give the per-entry byte size minus one.
|
||||||
|
func parseDeltaSetMap(b []byte, off int) (*deltaSetMap, error) {
|
||||||
|
if off == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
r := reader{b: b, pos: off}
|
||||||
|
format := r.u8()
|
||||||
|
entryFormat := r.u8()
|
||||||
|
var count int
|
||||||
|
switch format {
|
||||||
|
case 0:
|
||||||
|
count = int(r.u16())
|
||||||
|
case 1:
|
||||||
|
count = int(r.u32())
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: DeltaSetIndexMap: unsupported format %d", format)
|
||||||
|
}
|
||||||
|
innerBits := uint(entryFormat&0x0F) + 1
|
||||||
|
entrySize := int((entryFormat&0x30)>>4) + 1
|
||||||
|
innerMask := uint32(1)<<innerBits - 1
|
||||||
|
|
||||||
|
m := &deltaSetMap{outer: make([]uint32, count), inner: make([]uint32, count)}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
var entry uint32
|
||||||
|
for k := 0; k < entrySize; k++ {
|
||||||
|
entry = entry<<8 | uint32(r.u8())
|
||||||
|
}
|
||||||
|
m.inner[i] = entry & innerMask
|
||||||
|
m.outer[i] = entry >> innerBits
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: DeltaSetIndexMap: %w", r.err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hvarTable is the parsed HVAR table: the shared store plus the advance-width
|
||||||
|
// and optional left/right side-bearing DeltaSetIndexMaps. Only the advance-width
|
||||||
|
// map drives a metric here; the side-bearing maps are decoded for completeness.
|
||||||
|
type hvarTable struct {
|
||||||
|
store *metricVarStore
|
||||||
|
advMap *deltaSetMap
|
||||||
|
lsbMap *deltaSetMap
|
||||||
|
rsbMap *deltaSetMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// vvarTable is the parsed VVAR table: the shared store plus the advance-height
|
||||||
|
// and optional top/bottom side-bearing and vertical-origin maps.
|
||||||
|
type vvarTable struct {
|
||||||
|
store *metricVarStore
|
||||||
|
advMap *deltaSetMap
|
||||||
|
tsbMap *deltaSetMap
|
||||||
|
bsbMap *deltaSetMap
|
||||||
|
vorgMap *deltaSetMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// mvarTable is the parsed MVAR table: the shared store plus a map from each
|
||||||
|
// four-byte value tag to its (outer, inner) store indices.
|
||||||
|
type mvarTable struct {
|
||||||
|
store *metricVarStore
|
||||||
|
values map[string][2]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// delta returns the MVAR delta for value tag at norm, or zero when the tag is
|
||||||
|
// absent or the table carries no store.
|
||||||
|
func (m *mvarTable) delta(tag string, norm []int16) float64 {
|
||||||
|
v, ok := m.values[tag]
|
||||||
|
if !ok || m.store == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.store.delta(v[0], v[1], norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetricVarTable decodes the header common to HVAR and VVAR: a version, a
|
||||||
|
// required ItemVariationStore offset, and mapCount DeltaSetIndexMap offsets (3
|
||||||
|
// for HVAR, 4 for VVAR), all uint32 and relative to the table start.
|
||||||
|
func parseMetricVarTable(b []byte, mapCount int) (*metricVarStore, []*deltaSetMap, error) {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.u16() // majorVersion
|
||||||
|
r.u16() // minorVersion
|
||||||
|
storeOff := int(r.u32())
|
||||||
|
offs := make([]int, mapCount)
|
||||||
|
for i := range offs {
|
||||||
|
offs[i] = int(r.u32())
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("opentype: metric variations header: %w", r.err)
|
||||||
|
}
|
||||||
|
store, err := parseMetricVarStore(b, storeOff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
maps := make([]*deltaSetMap, mapCount)
|
||||||
|
for i, o := range offs {
|
||||||
|
m, err := parseDeltaSetMap(b, o)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
maps[i] = m
|
||||||
|
}
|
||||||
|
return store, maps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHVAR decodes the HVAR table into f.hvar.
|
||||||
|
func (f *Font) parseHVAR(b []byte) error {
|
||||||
|
store, maps, err := parseMetricVarTable(b, 3)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.hvar = &hvarTable{store: store, advMap: maps[0], lsbMap: maps[1], rsbMap: maps[2]}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVVAR decodes the VVAR table into f.vvar.
|
||||||
|
func (f *Font) parseVVAR(b []byte) error {
|
||||||
|
store, maps, err := parseMetricVarTable(b, 4)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.vvar = &vvarTable{store: store, advMap: maps[0], tsbMap: maps[1], bsbMap: maps[2], vorgMap: maps[3]}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMVAR decodes the MVAR table into f.mvar: a header, a run of value records
|
||||||
|
// (each a four-byte tag and an (outer, inner) index pair) and, unless there are
|
||||||
|
// none, a shared ItemVariationStore. The store offset here is a uint16.
|
||||||
|
func (f *Font) parseMVAR(b []byte) error {
|
||||||
|
r := reader{b: b}
|
||||||
|
r.u16() // majorVersion
|
||||||
|
r.u16() // minorVersion
|
||||||
|
r.u16() // reserved
|
||||||
|
valueRecordSize := int(r.u16())
|
||||||
|
valueRecordCount := int(r.u16())
|
||||||
|
storeOff := int(r.u16())
|
||||||
|
if r.err != nil {
|
||||||
|
return fmt.Errorf("opentype: MVAR header: %w", r.err)
|
||||||
|
}
|
||||||
|
base := r.pos
|
||||||
|
values := make(map[string][2]int, valueRecordCount)
|
||||||
|
for i := 0; i < valueRecordCount; i++ {
|
||||||
|
vr := reader{b: b, pos: base + i*valueRecordSize}
|
||||||
|
tag := readTag(&vr)
|
||||||
|
outer := int(vr.u16())
|
||||||
|
inner := int(vr.u16())
|
||||||
|
if vr.err != nil {
|
||||||
|
return fmt.Errorf("opentype: MVAR value record %d: %w", i, vr.err)
|
||||||
|
}
|
||||||
|
values[tag] = [2]int{outer, inner}
|
||||||
|
}
|
||||||
|
var store *metricVarStore
|
||||||
|
if storeOff != 0 {
|
||||||
|
s, err := parseMetricVarStore(b, storeOff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store = s
|
||||||
|
}
|
||||||
|
f.mvar = &mvarTable{store: store, values: values}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetricVariations parses the optional HVAR, VVAR and MVAR tables. Each is
|
||||||
|
// independent; absence is not an error. A present-but-malformed table is
|
||||||
|
// reported so a corrupt font fails cleanly.
|
||||||
|
func (f *Font) parseMetricVariations(tables map[string][]byte) error {
|
||||||
|
if b, ok := tables["HVAR"]; ok {
|
||||||
|
if err := f.parseHVAR(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["VVAR"]; ok {
|
||||||
|
if err := f.parseVVAR(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["MVAR"]; ok {
|
||||||
|
if err := f.parseMVAR(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// horizontalAdvanceDelta returns glyph gid's horizontal-advance delta in font
|
||||||
|
// units at normalized coordinate norm: the HVAR advance-width delta when the
|
||||||
|
// font carries HVAR, otherwise the gvar phantom-point fallback for a variable
|
||||||
|
// glyf font. It is zero for a static font (nil norm) or when nothing applies.
|
||||||
|
func (f *Font) horizontalAdvanceDelta(gid int, norm []int16) float64 {
|
||||||
|
if norm == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if f.hvar != nil {
|
||||||
|
outer, inner := f.hvar.advMap.lookup(gid)
|
||||||
|
return f.hvar.store.delta(outer, inner, norm)
|
||||||
|
}
|
||||||
|
return f.gvarAdvanceWidthDelta(gid, norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verticalAdvanceDelta returns glyph gid's advance-height delta in font units at
|
||||||
|
// normalized coordinate norm from the VVAR table, or zero when the font has no
|
||||||
|
// VVAR table or norm is nil.
|
||||||
|
func (f *Font) verticalAdvanceDelta(gid int, norm []int16) float64 {
|
||||||
|
if norm == nil || f.vvar == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
outer, inner := f.vvar.advMap.lookup(gid)
|
||||||
|
return f.vvar.store.delta(outer, inner, norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gvarAdvanceWidthDelta computes glyph gid's horizontal-advance delta from its
|
||||||
|
// 'gvar' phantom points — the fallback the specification prescribes when a
|
||||||
|
// variable font varies advances through gvar but ships no HVAR table. It decodes
|
||||||
|
// the simple glyph's default points, runs the same gvar delta accumulation used
|
||||||
|
// for outlines (gvar.go) over a buffer that includes the four TrueType phantom
|
||||||
|
// points, and returns the advance-width phantom's x delta (pp2) minus the left
|
||||||
|
// phantom's (pp1). It is zero for an empty glyph, a composite glyph, a
|
||||||
|
// missing/short gvar, or malformed data.
|
||||||
|
func (f *Font) gvarAdvanceWidthDelta(gid int, norm []int16) float64 {
|
||||||
|
gv := f.gvar
|
||||||
|
if gv == nil || gid < 0 || gid+1 >= len(gv.dataOffsets) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
start, end := f.loca[gid], f.loca[gid+1]
|
||||||
|
if start >= end || int(end) > len(f.glyf) {
|
||||||
|
return 0 // empty or out-of-range glyph
|
||||||
|
}
|
||||||
|
r := reader{b: f.glyf[start:end]}
|
||||||
|
n := int(r.i16())
|
||||||
|
r.skip(8) // xMin, yMin, xMax, yMax
|
||||||
|
if n < 0 {
|
||||||
|
return 0 // composite-glyph advance fallback is out of scope
|
||||||
|
}
|
||||||
|
contours, err := f.simpleGlyph(&r, n)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var pts []outlinePoint
|
||||||
|
var ends []int
|
||||||
|
for _, c := range contours {
|
||||||
|
pts = append(pts, c...)
|
||||||
|
ends = append(ends, len(pts)-1)
|
||||||
|
}
|
||||||
|
numReal := len(pts)
|
||||||
|
|
||||||
|
gstart := gv.dataArrayOff + int(gv.dataOffsets[gid])
|
||||||
|
gend := gv.dataArrayOff + int(gv.dataOffsets[gid+1])
|
||||||
|
if gstart >= gend || gend > len(gv.data) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
coords := make([]float64, gv.axisCount)
|
||||||
|
for i := range coords {
|
||||||
|
if i < len(norm) {
|
||||||
|
coords[i] = float64(norm[i]) / 16384.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accX := make([]float64, numReal+4)
|
||||||
|
accY := make([]float64, numReal+4)
|
||||||
|
if err := gv.applyGlyph(gv.data[gstart:gend], coords, ends, pts, accX, accY, false); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return accX[numReal+1] - accX[numReal]
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// This file exposes the parsed GSUB and GPOS tables to external callers and
|
||||||
|
// adds positional GSUB application: applying a feature's lookups only at
|
||||||
|
// selected glyph positions. That per-glyph masking is the mechanism a
|
||||||
|
// complex-text shaper needs for Arabic cursive joining, where the isol, init,
|
||||||
|
// medi and fina single-substitution features must each be applied only at the
|
||||||
|
// glyphs that take that joining form. Whole-run application (as done by Apply)
|
||||||
|
// remains available by passing a nil mask.
|
||||||
|
|
||||||
|
// GSUB is the exported handle to a font's parsed GSUB (glyph substitution)
|
||||||
|
// table, obtained via [Font.GSUB]. It exposes [GSUB.Apply] (whole-run) and
|
||||||
|
// [GSUB.ApplyMasked] (positional) so a shaper outside this package can drive
|
||||||
|
// substitution lookups directly.
|
||||||
|
type GSUB = gsub
|
||||||
|
|
||||||
|
// GPOS is the exported handle to a font's parsed GPOS (glyph positioning)
|
||||||
|
// table, obtained via [Font.GPOS]. Its [GPOS.Position] method runs positioning
|
||||||
|
// lookups (pair kerning, mark attachment, cursive attachment, ...) over a glyph
|
||||||
|
// run a caller has already substituted.
|
||||||
|
type GPOS = gpos
|
||||||
|
|
||||||
|
// GSUB returns the font's parsed GSUB table, or nil when the font carries no
|
||||||
|
// GSUB table (in which case no substitution is available).
|
||||||
|
func (f *Font) GSUB() *GSUB { return f.gsub }
|
||||||
|
|
||||||
|
// GPOS returns the font's parsed GPOS table, or nil when the font carries no
|
||||||
|
// GPOS table (in which case no positioning is available).
|
||||||
|
func (f *Font) GPOS() *GPOS { return f.gpos }
|
||||||
|
|
||||||
|
// GlyphAdvance returns the horizontal advance of glyph g in font units, or 0
|
||||||
|
// when g is out of range. A shaper multiplies it by [Face.Scale] to obtain
|
||||||
|
// pixels, and feeds the per-glyph advances to [GPOS.Position] so mark
|
||||||
|
// attachment can pull a diacritic back onto its base.
|
||||||
|
func (f *Font) GlyphAdvance(g GlyphIndex) int {
|
||||||
|
if int(g) < len(f.advances) {
|
||||||
|
return f.advances[g]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureApp requests one GSUB feature applied over a subset of a glyph run.
|
||||||
|
// Tag is the 4-byte feature tag (for example "init" or "liga"). Positions
|
||||||
|
// selects, by run index, the glyphs the feature's lookups may start at: a
|
||||||
|
// lookup is attempted at glyph i only when Positions[i] is true. A nil
|
||||||
|
// Positions applies the feature over the whole run (identical to Apply); a
|
||||||
|
// non-nil Positions restricts it, with indices at or past its end treated as
|
||||||
|
// false.
|
||||||
|
//
|
||||||
|
// Masking is intended for the length-preserving single substitutions OpenType
|
||||||
|
// files the Arabic positional-form features (isol/init/medi/fina) as, so the
|
||||||
|
// mask indices stay aligned to the run as it is rewritten.
|
||||||
|
type FeatureApp struct {
|
||||||
|
Tag string
|
||||||
|
Positions []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMasked applies each feature in feats, in order, to the glyph run and
|
||||||
|
// returns the substituted run; the input slice is not modified. For a feature
|
||||||
|
// with a nil Positions mask the feature's lookups run over the whole run (as
|
||||||
|
// [GSUB.Apply] does); with a non-nil mask they are attempted only at the glyphs
|
||||||
|
// the mask selects — the mechanism Arabic cursive shaping needs to apply
|
||||||
|
// init/medi/fina/isol each at just the glyphs in that joining position.
|
||||||
|
//
|
||||||
|
// Features are resolved with the script-agnostic default fallback (the DFLT
|
||||||
|
// script, then the union across every script's default LangSys), so features
|
||||||
|
// filed only under a real script such as "arab" or "latn" still resolve. An
|
||||||
|
// empty feature list or an empty run leaves the input unchanged.
|
||||||
|
//
|
||||||
|
// Use [GSUB.ApplyMaskedTracked] instead when the caller must follow which
|
||||||
|
// source glyph each output glyph derives from across a decomposition or
|
||||||
|
// ligature (as a shaper does to re-derive Arabic joining masks after ccmp
|
||||||
|
// decomposes letters, and to map output glyphs back to text clusters).
|
||||||
|
func (g *gsub) ApplyMasked(glyphs []GlyphIndex, feats []FeatureApp) []GlyphIndex {
|
||||||
|
out, _ := g.applyMasked(glyphs, nil, feats)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMaskedTracked is [GSUB.ApplyMasked] that also propagates a per-glyph
|
||||||
|
// cluster (source-position) slice through the substitution. clusters gives the
|
||||||
|
// starting cluster of each input glyph; nil starts them at 0, 1, 2, ... The
|
||||||
|
// returned cluster slice is the same length as the returned run: a glyph
|
||||||
|
// produced by decomposing input glyph k carries cluster[k], and a ligature
|
||||||
|
// carries the cluster of its first component. Neither input slice is modified.
|
||||||
|
//
|
||||||
|
// This is the primitive a complex-text shaper drives: apply ccmp with tracking
|
||||||
|
// to learn where each decomposed glyph came from, rebuild the init/medi/fina
|
||||||
|
// masks against the decomposed run, apply those, and read the final clusters to
|
||||||
|
// map glyphs back to the source text.
|
||||||
|
func (g *gsub) ApplyMaskedTracked(glyphs []GlyphIndex, clusters []int, feats []FeatureApp) ([]GlyphIndex, []int) {
|
||||||
|
if clusters == nil {
|
||||||
|
clusters = make([]int, len(glyphs))
|
||||||
|
for i := range clusters {
|
||||||
|
clusters[i] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.applyMasked(glyphs, clusters, feats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMasked is the shared implementation of ApplyMasked and
|
||||||
|
// ApplyMaskedTracked. A nil clusters disables cluster tracking (the plain
|
||||||
|
// ApplyMasked fast path); a non-nil clusters is threaded through the applier
|
||||||
|
// and returned alongside the substituted run.
|
||||||
|
func (g *gsub) applyMasked(glyphs []GlyphIndex, clusters []int, feats []FeatureApp) ([]GlyphIndex, []int) {
|
||||||
|
if len(feats) == 0 || len(glyphs) == 0 {
|
||||||
|
return glyphs, clusters
|
||||||
|
}
|
||||||
|
out := append([]GlyphIndex(nil), glyphs...)
|
||||||
|
a := &gsubApplier{lookups: g.lookups, gdef: g.gdef}
|
||||||
|
if clusters != nil {
|
||||||
|
a.clusters = append([]int(nil), clusters...)
|
||||||
|
}
|
||||||
|
for _, fa := range feats {
|
||||||
|
idxs := g.selectLookupsDefault(map[string]bool{fa.Tag: true})
|
||||||
|
for _, li := range idxs {
|
||||||
|
if li >= len(g.lookups) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = a.applyLookupMasked(g.lookups[li], out, fa.Positions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, a.clusters
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLookupMasked applies one lookup like applyLookup, but restricted to the
|
||||||
|
// positions the mask selects. A nil mask means the whole run (delegating to
|
||||||
|
// applyLookup). With a mask, a substitution is attempted at glyph i only when
|
||||||
|
// mask[i] is true (indices past the mask are treated as false); this is meant
|
||||||
|
// for length-preserving single substitutions so the mask stays aligned.
|
||||||
|
func (a *gsubApplier) applyLookupMasked(lk gsubLookup, glyphs []GlyphIndex, mask []bool) []GlyphIndex {
|
||||||
|
if mask == nil {
|
||||||
|
return a.applyLookup(lk, glyphs)
|
||||||
|
}
|
||||||
|
a.skip = a.skipperFor(lk)
|
||||||
|
masked := func(i int) bool { return i < len(mask) && mask[i] && !a.skip.skip(glyphs[i]) }
|
||||||
|
if lk.reverse {
|
||||||
|
for i := len(glyphs) - 1; i >= 0; i-- {
|
||||||
|
if !masked(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, st := range lk.subtables {
|
||||||
|
if out, _, ok := st.sub(a, glyphs, i); ok {
|
||||||
|
glyphs = out
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return glyphs
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
for i < len(glyphs) {
|
||||||
|
if !masked(i) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applied := false
|
||||||
|
for _, st := range lk.subtables {
|
||||||
|
if out, next, ok := st.sub(a, glyphs, i); ok {
|
||||||
|
glyphs = out
|
||||||
|
i = next
|
||||||
|
applied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !applied {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return glyphs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position runs the GPOS positioning lookups activated by the given feature
|
||||||
|
// tags over the glyph run and returns one adjustment per glyph, in font units.
|
||||||
|
// advances gives each glyph's base horizontal advance (used to pull marks back
|
||||||
|
// onto their base) and may be nil (treated as all-zero). Features are resolved
|
||||||
|
// with the same script-agnostic default fallback as kerning, so kern/mark/mkmk
|
||||||
|
// filed under a real script resolve. It is the exported entry point a shaper
|
||||||
|
// uses to position a run it substituted itself.
|
||||||
|
func (p *gpos) Position(glyphs []GlyphIndex, advances []int, features ...string) []GlyphPosition {
|
||||||
|
return p.position(glyphs, advances, features...)
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fpoint is a point in device (pixel) space.
|
||||||
|
type fpoint struct{ x, y float64 }
|
||||||
|
|
||||||
|
// supersample is the per-axis oversampling factor; the rasteriser takes
|
||||||
|
// ss*ss coverage samples per output pixel for anti-aliasing.
|
||||||
|
const supersample = 4
|
||||||
|
|
||||||
|
// flattenContour scales a font-unit contour to device space (flipping the
|
||||||
|
// y axis so font "up" becomes image "down") and flattens its quadratic
|
||||||
|
// segments into a closed polygon. It returns nil for a degenerate contour of
|
||||||
|
// fewer than two points.
|
||||||
|
func flattenContour(c contour, scale float64) []fpoint {
|
||||||
|
n := len(c)
|
||||||
|
if n < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type dp struct {
|
||||||
|
x, y float64
|
||||||
|
on bool
|
||||||
|
}
|
||||||
|
pts := make([]dp, n)
|
||||||
|
for i, p := range c {
|
||||||
|
pts[i] = dp{x: p.x * scale, y: -p.y * scale, on: p.on}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an implied on-curve midpoint between any two consecutive
|
||||||
|
// off-curve points (walking the contour cyclically).
|
||||||
|
q := make([]dp, 0, n*2)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
a := pts[i]
|
||||||
|
q = append(q, a)
|
||||||
|
b := pts[(i+1)%n]
|
||||||
|
if !a.on && !b.on {
|
||||||
|
q = append(q, dp{x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, on: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate so the walk starts on an on-curve point (one is guaranteed to
|
||||||
|
// exist for n >= 2 after midpoint insertion).
|
||||||
|
s := 0
|
||||||
|
for !q[s].on {
|
||||||
|
s++
|
||||||
|
}
|
||||||
|
ordered := append(append([]dp{}, q[s:]...), q[:s]...)
|
||||||
|
m := len(ordered)
|
||||||
|
|
||||||
|
poly := []fpoint{{x: ordered[0].x, y: ordered[0].y}}
|
||||||
|
i := 0
|
||||||
|
for i < m {
|
||||||
|
next := ordered[(i+1)%m]
|
||||||
|
if next.on {
|
||||||
|
poly = append(poly, fpoint{x: next.x, y: next.y})
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur := ordered[i]
|
||||||
|
end := ordered[(i+2)%m]
|
||||||
|
flattenQuad(&poly, cur.x, cur.y, next.x, next.y, end.x, end.y)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return poly
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenQuad appends a flattened quadratic Bézier (from the start point,
|
||||||
|
// already the last element of *poly, through control point (cx,cy) to end
|
||||||
|
// point (x1,y1)) using a step count adapted to the curve's device-space size.
|
||||||
|
func flattenQuad(poly *[]fpoint, x0, y0, cx, cy, x1, y1 float64) {
|
||||||
|
d := math.Hypot(cx-x0, cy-y0) + math.Hypot(x1-cx, y1-cy)
|
||||||
|
steps := int(d / 3.0)
|
||||||
|
if steps < 1 {
|
||||||
|
steps = 1
|
||||||
|
}
|
||||||
|
for i := 1; i <= steps; i++ {
|
||||||
|
t := float64(i) / float64(steps)
|
||||||
|
mt := 1 - t
|
||||||
|
x := mt*mt*x0 + 2*mt*t*cx + t*t*x1
|
||||||
|
y := mt*mt*y0 + 2*mt*t*cy + t*t*y1
|
||||||
|
*poly = append(*poly, fpoint{x: x, y: y})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLeft returns a positive value if point (x,y) is left of the directed edge
|
||||||
|
// a→b, negative if right, and zero if collinear.
|
||||||
|
func isLeft(a, b fpoint, x, y float64) float64 {
|
||||||
|
return (b.x-a.x)*(y-a.y) - (x-a.x)*(b.y-a.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insideNonZero reports whether the point (x,y) is inside the polygon set
|
||||||
|
// under the non-zero winding rule.
|
||||||
|
func insideNonZero(polys [][]fpoint, x, y float64) bool {
|
||||||
|
wn := 0
|
||||||
|
for _, poly := range polys {
|
||||||
|
n := len(poly)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
a := poly[i]
|
||||||
|
b := poly[(i+1)%n]
|
||||||
|
if a.y <= y {
|
||||||
|
if b.y > y && isLeft(a, b, x, y) > 0 {
|
||||||
|
wn++
|
||||||
|
}
|
||||||
|
} else if b.y <= y && isLeft(a, b, x, y) < 0 {
|
||||||
|
wn--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wn != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// rasterize scan-converts the device-space polygons into an 8-bit alpha
|
||||||
|
// coverage mask using supersampled non-zero winding. It returns the mask's
|
||||||
|
// device-space bounds (relative to the glyph origin) and an *image.Alpha whose
|
||||||
|
// own rectangle starts at (0,0). An empty polygon set yields an empty rect and
|
||||||
|
// a nil mask.
|
||||||
|
func rasterize(polys [][]fpoint) (image.Rectangle, *image.Alpha) {
|
||||||
|
if len(polys) == 0 {
|
||||||
|
return image.Rectangle{}, nil
|
||||||
|
}
|
||||||
|
minX, minY := math.Inf(1), math.Inf(1)
|
||||||
|
maxX, maxY := math.Inf(-1), math.Inf(-1)
|
||||||
|
for _, poly := range polys {
|
||||||
|
for _, p := range poly {
|
||||||
|
if p.x < minX {
|
||||||
|
minX = p.x
|
||||||
|
}
|
||||||
|
if p.x > maxX {
|
||||||
|
maxX = p.x
|
||||||
|
}
|
||||||
|
if p.y < minY {
|
||||||
|
minY = p.y
|
||||||
|
}
|
||||||
|
if p.y > maxY {
|
||||||
|
maxY = p.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x0 := int(math.Floor(minX))
|
||||||
|
y0 := int(math.Floor(minY))
|
||||||
|
x1 := int(math.Ceil(maxX))
|
||||||
|
y1 := int(math.Ceil(maxY))
|
||||||
|
w, h := x1-x0, y1-y0
|
||||||
|
|
||||||
|
alpha := image.NewAlpha(image.Rect(0, 0, w, h))
|
||||||
|
for py := 0; py < h; py++ {
|
||||||
|
for px := 0; px < w; px++ {
|
||||||
|
count := 0
|
||||||
|
for sy := 0; sy < supersample; sy++ {
|
||||||
|
for sx := 0; sx < supersample; sx++ {
|
||||||
|
fx := float64(x0+px) + (float64(sx)+0.5)/supersample
|
||||||
|
fy := float64(y0+py) + (float64(sy)+0.5)/supersample
|
||||||
|
if insideNonZero(polys, fx, fy) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alpha.SetAlpha(px, py, color.Alpha{A: uint8(count * 255 / (supersample * supersample))})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image.Rect(x0, y0, x1, y1), alpha
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// This file exposes read-only access to the raw, undecoded sfnt table bytes the
|
||||||
|
// font was parsed from. A consumer that embeds a font (a PDF or EPUB writer) or
|
||||||
|
// inspects a table this package does not decode can read the exact table data
|
||||||
|
// without re-parsing the container itself.
|
||||||
|
|
||||||
|
// TableTags returns the four-byte tags of every table in the font, sorted
|
||||||
|
// lexicographically. The returned slice is freshly allocated and owned by the
|
||||||
|
// caller. Tags retain their sfnt spelling, including any trailing space (for
|
||||||
|
// example "cvt " and "OS/2").
|
||||||
|
func (f *Font) TableTags() []string {
|
||||||
|
tags := make([]string, 0, len(f.tables))
|
||||||
|
for t := range f.tables {
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
|
sort.Strings(tags)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns a copy of the raw bytes of the table named by its four-byte tag,
|
||||||
|
// and ok reporting whether the font carries that table. The tag must be the exact
|
||||||
|
// sfnt spelling including any trailing space (for example "cvt " or "OS/2"). The
|
||||||
|
// returned slice is a fresh copy the caller may retain or mutate; it never aliases
|
||||||
|
// the font's backing data.
|
||||||
|
func (f *Font) Table(tag string) (data []byte, ok bool) {
|
||||||
|
b, ok := f.tables[tag]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return append([]byte(nil), b...), true
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "encoding/binary"
|
||||||
|
|
||||||
|
// reader is a big-endian sequential reader over a byte slice with sticky
|
||||||
|
// error tracking: any read past the end records errTruncated and returns zero,
|
||||||
|
// so a parser can read a run of fields and check err once at the end.
|
||||||
|
type reader struct {
|
||||||
|
b []byte
|
||||||
|
pos int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// u8 reads one unsigned byte.
|
||||||
|
func (r *reader) u8() uint8 {
|
||||||
|
if r.pos+1 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := r.b[r.pos]
|
||||||
|
r.pos++
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// u16 reads a big-endian uint16.
|
||||||
|
func (r *reader) u16() uint16 {
|
||||||
|
if r.pos+2 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := binary.BigEndian.Uint16(r.b[r.pos:])
|
||||||
|
r.pos += 2
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// u32 reads a big-endian uint32.
|
||||||
|
func (r *reader) u32() uint32 {
|
||||||
|
if r.pos+4 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := binary.BigEndian.Uint32(r.b[r.pos:])
|
||||||
|
r.pos += 4
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// i16 reads a big-endian signed int16.
|
||||||
|
func (r *reader) i16() int16 { return int16(r.u16()) }
|
||||||
|
|
||||||
|
// u24 reads a big-endian unsigned 24-bit integer (used by cmap format 14's
|
||||||
|
// variation-selector and Unicode-value fields).
|
||||||
|
func (r *reader) u24() uint32 {
|
||||||
|
if r.pos+3 > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := uint32(r.b[r.pos])<<16 | uint32(r.b[r.pos+1])<<8 | uint32(r.b[r.pos+2])
|
||||||
|
r.pos += 3
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// f2dot14 reads an F2Dot14 fixed-point value as a float64.
|
||||||
|
func (r *reader) f2dot14() float64 { return float64(r.i16()) / 16384.0 }
|
||||||
|
|
||||||
|
// skip advances the position by n bytes, recording an error if that runs past
|
||||||
|
// the end of the slice.
|
||||||
|
func (r *reader) skip(n int) {
|
||||||
|
if r.pos+n > len(r.b) {
|
||||||
|
r.err = errTruncated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pos += n
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the shared machinery for writing a fresh sfnt container: the
|
||||||
|
// table directory, per-table checksums and the head checkSumAdjustment. Both the
|
||||||
|
// TrueType subsetter (subset.go) and the variable-instance serialiser
|
||||||
|
// (instance.go) rebuild a font from a set of tables and emit it through
|
||||||
|
// buildSFNT, so a single, tested writer covers every produced font.
|
||||||
|
|
||||||
|
// buildSFNT packs tables into an sfnt container with the given sfnt version tag,
|
||||||
|
// laying out the table directory (records sorted by tag, 4-byte-aligned bodies),
|
||||||
|
// filling in each record's checksum, offset and length, and patching head's
|
||||||
|
// checkSumAdjustment so the emitted font is a valid, self-consistent sfnt. A
|
||||||
|
// "head" table, when present, must be at least 12 bytes (its checkSumAdjustment
|
||||||
|
// field lives at offset 8); the callers always supply the full 54-byte table.
|
||||||
|
func buildSFNT(version uint32, tables map[string][]byte) []byte {
|
||||||
|
tags := make([]string, 0, len(tables))
|
||||||
|
for t := range tables {
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
|
sort.Strings(tags)
|
||||||
|
|
||||||
|
n := len(tags)
|
||||||
|
// searchRange/entrySelector/rangeShift describe the largest power of two not
|
||||||
|
// exceeding n, per the sfnt spec's binary-search hint fields.
|
||||||
|
entrySelector := 0
|
||||||
|
for (1 << (entrySelector + 1)) <= n {
|
||||||
|
entrySelector++
|
||||||
|
}
|
||||||
|
searchRange := (1 << entrySelector) * 16
|
||||||
|
rangeShift := n*16 - searchRange
|
||||||
|
|
||||||
|
headerLen := 12 + 16*n
|
||||||
|
offsets := make(map[string]int, n)
|
||||||
|
var body []byte
|
||||||
|
for _, t := range tags {
|
||||||
|
offsets[t] = headerLen + len(body)
|
||||||
|
body = append(body, tables[t]...)
|
||||||
|
for len(body)%4 != 0 {
|
||||||
|
body = append(body, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, headerLen)
|
||||||
|
binary.BigEndian.PutUint32(out[0:], version)
|
||||||
|
binary.BigEndian.PutUint16(out[4:], uint16(n))
|
||||||
|
binary.BigEndian.PutUint16(out[6:], uint16(searchRange))
|
||||||
|
binary.BigEndian.PutUint16(out[8:], uint16(entrySelector))
|
||||||
|
binary.BigEndian.PutUint16(out[10:], uint16(rangeShift))
|
||||||
|
for i, t := range tags {
|
||||||
|
rec := 12 + i*16
|
||||||
|
copy(out[rec:], t)
|
||||||
|
binary.BigEndian.PutUint32(out[rec+4:], sfntChecksum(tables[t]))
|
||||||
|
binary.BigEndian.PutUint32(out[rec+8:], uint32(offsets[t]))
|
||||||
|
binary.BigEndian.PutUint32(out[rec+12:], uint32(len(tables[t])))
|
||||||
|
}
|
||||||
|
out = append(out, body...)
|
||||||
|
|
||||||
|
// head.checkSumAdjustment = 0xB1B0AFBA - checksum(whole file), computed with
|
||||||
|
// the field itself read as zero (it is still zero in out at this point).
|
||||||
|
if headOff, ok := offsets["head"]; ok {
|
||||||
|
adj := 0xB1B0AFBA - sfntChecksum(out)
|
||||||
|
binary.BigEndian.PutUint32(out[headOff+8:], adj)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sfntChecksum is the sfnt table checksum: the sum modulo 2^32 of the data read
|
||||||
|
// as big-endian uint32 words, the final word zero-padded when the length is not a
|
||||||
|
// multiple of four.
|
||||||
|
func sfntChecksum(b []byte) uint32 {
|
||||||
|
var sum uint32
|
||||||
|
var i int
|
||||||
|
for ; i+4 <= len(b); i += 4 {
|
||||||
|
sum += binary.BigEndian.Uint32(b[i:])
|
||||||
|
}
|
||||||
|
if rem := len(b) - i; rem > 0 {
|
||||||
|
var tail [4]byte
|
||||||
|
copy(tail[:], b[i:])
|
||||||
|
sum += binary.BigEndian.Uint32(tail[:])
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// minimalCmap returns a structurally valid, essentially empty cmap table: a
|
||||||
|
// single format-4 (3,1 Unicode BMP) subtable whose only segment is the mandatory
|
||||||
|
// 0xFFFF sentinel, so it maps no character to a real glyph. A subset embedded in
|
||||||
|
// a PDF as a CIDFontType2 is addressed by glyph id through the CIDToGIDMap and
|
||||||
|
// never consults a cmap, but opentype.Parse (and other consumers) require a cmap
|
||||||
|
// table to be present and decodable; this satisfies that without carrying the
|
||||||
|
// original, now-misindexed mappings.
|
||||||
|
func minimalCmap() []byte {
|
||||||
|
var sub []byte
|
||||||
|
put16 := func(v uint16) { sub = binary.BigEndian.AppendUint16(sub, v) }
|
||||||
|
put16(4) // format
|
||||||
|
put16(24) // length (4-header + 8 fixed + one segment of 8)
|
||||||
|
put16(0) // language
|
||||||
|
put16(2) // segCountX2 (segCount 1)
|
||||||
|
put16(2) // searchRange
|
||||||
|
put16(0) // entrySelector
|
||||||
|
put16(0) // rangeShift
|
||||||
|
put16(0xFFFF) // endCode[0] (sentinel)
|
||||||
|
put16(0) // reservedPad
|
||||||
|
put16(0xFFFF) // startCode[0]
|
||||||
|
put16(1) // idDelta[0]
|
||||||
|
put16(0) // idRangeOffset[0]
|
||||||
|
|
||||||
|
var t []byte
|
||||||
|
putT16 := func(v uint16) { t = binary.BigEndian.AppendUint16(t, v) }
|
||||||
|
putT32 := func(v uint32) { t = binary.BigEndian.AppendUint32(t, v) }
|
||||||
|
putT16(0) // version
|
||||||
|
putT16(1) // numTables
|
||||||
|
putT16(3) // platformID (Windows)
|
||||||
|
putT16(1) // encodingID (Unicode BMP)
|
||||||
|
putT32(12)
|
||||||
|
return append(t, sub...)
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
// This file wires the OpenType Layout tables (GSUB, GPOS and the legacy kern
|
||||||
|
// table, parsed in gsub.go/gpos.go/kern.go) into the public Face API: Shape
|
||||||
|
// runs glyph substitution over a text run, while Kern and MeasureKerned apply
|
||||||
|
// pair-adjustment kerning. A font without the relevant tables behaves as a
|
||||||
|
// plain unshaped, unkerned font.
|
||||||
|
|
||||||
|
// Shape maps text to its glyph run and applies the GSUB lookups activated by
|
||||||
|
// the given feature tags (for example "liga" for standard ligatures, "smcp"
|
||||||
|
// for small caps). Each rune maps through the font's cmap; an unmapped rune
|
||||||
|
// becomes glyph 0 (.notdef). With no GSUB table, no features, or features that
|
||||||
|
// match no lookups, the run is the plain cmap mapping.
|
||||||
|
func (fc *Face) Shape(text string, features ...string) []GlyphIndex {
|
||||||
|
var glyphs []GlyphIndex
|
||||||
|
for _, r := range text {
|
||||||
|
gid, _ := fc.font.GlyphIndex(r) // unmapped -> 0 (.notdef)
|
||||||
|
glyphs = append(glyphs, gid)
|
||||||
|
}
|
||||||
|
if fc.font.gsub != nil {
|
||||||
|
glyphs = fc.font.gsub.Apply(glyphs, features...)
|
||||||
|
}
|
||||||
|
return glyphs
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionedGlyph is one glyph of a positioned run produced by ShapePositioned:
|
||||||
|
// the glyph to draw, the pen-relative placement offsets (XOffset, YOffset) GPOS
|
||||||
|
// assigned it (a non-zero YOffset lifts an attached diacritic onto its base),
|
||||||
|
// and the advance to move the pen by afterwards, all in whole pixels at the
|
||||||
|
// face's size.
|
||||||
|
type PositionedGlyph struct {
|
||||||
|
Glyph GlyphIndex
|
||||||
|
XOffset int
|
||||||
|
YOffset int
|
||||||
|
XAdvance int
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultPosFeatures are the GPOS features ShapePositioned always activates in
|
||||||
|
// addition to any the caller requests: pair/positional kerning ("kern"),
|
||||||
|
// mark-to-base/ligature attachment ("mark") and mark-to-mark stacking ("mkmk").
|
||||||
|
var defaultPosFeatures = []string{"kern", "mark", "mkmk"}
|
||||||
|
|
||||||
|
// ShapePositioned maps text to a fully positioned glyph run: each rune maps
|
||||||
|
// through the cmap (an unmapped rune becomes glyph 0), the requested GSUB
|
||||||
|
// features are applied (ligatures, contextual substitution, ...), then GPOS
|
||||||
|
// positions the resulting glyphs — pair kerning, single/cursive adjustment and
|
||||||
|
// mark attachment (types 4/5/6 pull diacritics onto their base). The positioning
|
||||||
|
// features are the caller's features plus the always-on kern/mark/mkmk set.
|
||||||
|
//
|
||||||
|
// The result has one PositionedGlyph per output glyph, with placement and
|
||||||
|
// advance in whole pixels at the face's size. A font lacking GSUB or GPOS simply
|
||||||
|
// skips that stage; with neither the run is the plain cmap mapping at its
|
||||||
|
// unadjusted advances.
|
||||||
|
func (fc *Face) ShapePositioned(text string, features ...string) []PositionedGlyph {
|
||||||
|
var glyphs []GlyphIndex
|
||||||
|
for _, r := range text {
|
||||||
|
gid, _ := fc.font.GlyphIndex(r) // unmapped -> 0 (.notdef)
|
||||||
|
glyphs = append(glyphs, gid)
|
||||||
|
}
|
||||||
|
if fc.font.gsub != nil {
|
||||||
|
glyphs = fc.font.gsub.Apply(glyphs, features...)
|
||||||
|
}
|
||||||
|
// Base horizontal advances in font units, indexed by glyph, feed both the
|
||||||
|
// mark-attachment pull-back and the final per-glyph advance.
|
||||||
|
advances := make([]int, len(glyphs))
|
||||||
|
for i, g := range glyphs {
|
||||||
|
advances[i] = fc.font.advances[g]
|
||||||
|
}
|
||||||
|
var positions []GlyphPosition
|
||||||
|
if fc.font.gpos != nil {
|
||||||
|
posFeatures := append(append([]string(nil), features...), defaultPosFeatures...)
|
||||||
|
positions = fc.font.gpos.position(glyphs, advances, posFeatures...)
|
||||||
|
}
|
||||||
|
out := make([]PositionedGlyph, len(glyphs))
|
||||||
|
for i, g := range glyphs {
|
||||||
|
adv := advances[i]
|
||||||
|
var xOff, yOff int
|
||||||
|
if positions != nil {
|
||||||
|
xOff = positions[i].XOffset
|
||||||
|
yOff = positions[i].YOffset
|
||||||
|
adv += positions[i].XAdvance
|
||||||
|
}
|
||||||
|
out[i] = PositionedGlyph{
|
||||||
|
Glyph: g,
|
||||||
|
XOffset: roundInt(float64(xOff) * fc.scale),
|
||||||
|
YOffset: roundInt(float64(yOff) * fc.scale),
|
||||||
|
XAdvance: roundInt(float64(adv) * fc.scale),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kern returns the horizontal kerning adjustment between the consecutive runes
|
||||||
|
// prev and r, in whole pixels at the face's size. GPOS pair positioning is
|
||||||
|
// preferred; the legacy kern table is used as a fallback. It is zero when either
|
||||||
|
// rune is unmapped or the font carries no kerning for the pair.
|
||||||
|
//
|
||||||
|
// The result is rounded to a whole pixel. A text layer that sets type at (or
|
||||||
|
// near) the font's own em — where one pixel spans a large design quantum and a
|
||||||
|
// single-pixel kern would quantise away most of the sub-pixel pair adjustment —
|
||||||
|
// should measure with KernUnits (or KernIndexUnits) instead, which return the
|
||||||
|
// unrounded font-unit value, exactly as AdvanceIndexUnits complements
|
||||||
|
// AdvanceIndex.
|
||||||
|
func (fc *Face) Kern(prev, r rune) int {
|
||||||
|
return roundInt(fc.KernUnits(prev, r) * fc.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernIndexUnits returns the horizontal kerning adjustment between the
|
||||||
|
// consecutive glyphs left and right in font units (unrounded) — the value Kern
|
||||||
|
// scales and rounds to pixels. GPOS pair positioning is preferred; the legacy
|
||||||
|
// kern table is the fallback. It is the by-glyph-index, font-unit counterpart of
|
||||||
|
// Kern, the exact primitive a shaper or a text engine wants: kerning applies
|
||||||
|
// between shaped glyphs (which may be ligatures), and a caller typesetting at
|
||||||
|
// the design em keeps the sub-pixel adjustment that Kern's whole-pixel rounding
|
||||||
|
// would discard. A caller multiplies the result by Face.Scale to obtain pixels
|
||||||
|
// (mirroring AdvanceIndexUnits). The adjustment is zero when the font carries no
|
||||||
|
// kerning for the pair.
|
||||||
|
func (fc *Face) KernIndexUnits(left, right GlyphIndex) float64 {
|
||||||
|
kn := Kerner{gpos: fc.font.gpos, kern: fc.font.kern}
|
||||||
|
return float64(kn.Kerning(left, right))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernUnits returns the horizontal kerning adjustment between the consecutive
|
||||||
|
// runes prev and r in font units (unrounded) — the rune-keyed, font-unit
|
||||||
|
// counterpart of Kern. Each rune maps through the cmap; the result is zero when
|
||||||
|
// either rune is unmapped or the font carries no kerning for the pair. A caller
|
||||||
|
// multiplies by Face.Scale to obtain pixels; see KernIndexUnits for the
|
||||||
|
// by-glyph-index form a shaper drives on an already-substituted run.
|
||||||
|
func (fc *Face) KernUnits(prev, r rune) float64 {
|
||||||
|
lg, ok := fc.font.GlyphIndex(prev)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rg, ok := fc.font.GlyphIndex(r)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fc.KernIndexUnits(lg, rg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeasureKerned returns the total advance width of s in pixels like Measure,
|
||||||
|
// but additionally applies the font's kerning between each pair of consecutive
|
||||||
|
// runes. For a font with no kerning it equals Measure.
|
||||||
|
func (fc *Face) MeasureKerned(s string) int {
|
||||||
|
total := 0
|
||||||
|
var prev rune
|
||||||
|
havePrev := false
|
||||||
|
for _, r := range s {
|
||||||
|
if havePrev {
|
||||||
|
total += fc.Kern(prev, r)
|
||||||
|
}
|
||||||
|
total += fc.Advance(r)
|
||||||
|
prev = r
|
||||||
|
havePrev = true
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file implements TrueType ('glyf') glyph subsetting: rebuilding a minimal,
|
||||||
|
// self-contained sfnt that carries only a requested set of glyphs (and every
|
||||||
|
// glyph a composite among them references). It is the primitive a PDF or EPUB
|
||||||
|
// embedder needs so it can ship a subset font program instead of the whole face,
|
||||||
|
// and so it does not have to re-parse and re-serialise the sfnt container itself.
|
||||||
|
// CFF ('CFF ') charstring subsetting lives in subsetcff.go.
|
||||||
|
|
||||||
|
// componentRef locates one component-glyph reference inside a composite glyph's
|
||||||
|
// raw bytes: the byte offset of its two-byte glyph-id field and the glyph id
|
||||||
|
// stored there. Subsetting reads the ids to compute the closure and rewrites the
|
||||||
|
// field at the offset to the component's new id.
|
||||||
|
type componentRef struct {
|
||||||
|
offset int
|
||||||
|
gid GlyphIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// componentRefs returns the component-glyph references of a raw glyph. It returns
|
||||||
|
// nil for an empty or simple glyph (numberOfContours >= 0) and, for a composite,
|
||||||
|
// one entry per component in stream order. The walk is bounds-checked: a record
|
||||||
|
// that would read past the end of data ends the scan, so a truncated composite
|
||||||
|
// yields the components decodable so far rather than a panic.
|
||||||
|
func componentRefs(data []byte) []componentRef {
|
||||||
|
if len(data) < 10 || sbe16(data) >= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var refs []componentRef
|
||||||
|
p := 10
|
||||||
|
for p+4 <= len(data) {
|
||||||
|
flags := be16(data[p:])
|
||||||
|
refs = append(refs, componentRef{offset: p + 2, gid: GlyphIndex(be16(data[p+2:]))})
|
||||||
|
p += 4
|
||||||
|
if flags&compArgsAreWords != 0 {
|
||||||
|
p += 4
|
||||||
|
} else {
|
||||||
|
p += 2
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case flags&compWeHaveScale != 0:
|
||||||
|
p += 2
|
||||||
|
case flags&compXAndYScale != 0:
|
||||||
|
p += 4
|
||||||
|
case flags&compTwoByTwo != 0:
|
||||||
|
p += 8
|
||||||
|
}
|
||||||
|
if flags&compMoreComponents == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawGlyph returns glyph gid's raw bytes from the glyf table (the loca-delimited
|
||||||
|
// slice), or nil for an empty glyph. The caller must not mutate the result; it
|
||||||
|
// aliases the font's backing data.
|
||||||
|
func (f *Font) rawGlyph(gid GlyphIndex) []byte {
|
||||||
|
start, end := f.loca[gid], f.loca[gid+1]
|
||||||
|
if start >= end || int(end) > len(f.glyf) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.glyf[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetClosure returns the sorted set of glyph ids to keep: the requested gids,
|
||||||
|
// glyph 0 (.notdef, always retained so the subset has a valid missing glyph), and
|
||||||
|
// every glyph transitively referenced by a composite among them. A requested id
|
||||||
|
// outside the font's glyph range is an error. Cycles are terminated by the
|
||||||
|
// membership set, so a self- or mutually-referential composite is handled.
|
||||||
|
func (f *Font) subsetClosure(gids []GlyphIndex) ([]GlyphIndex, error) {
|
||||||
|
needed := map[GlyphIndex]bool{}
|
||||||
|
var walk func(gid GlyphIndex) error
|
||||||
|
walk = func(gid GlyphIndex) error {
|
||||||
|
if int(gid) >= f.numGlyphs {
|
||||||
|
return fmt.Errorf("opentype: subset: glyph index %d out of range", int(gid))
|
||||||
|
}
|
||||||
|
if needed[gid] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
needed[gid] = true
|
||||||
|
for _, ref := range componentRefs(f.rawGlyph(gid)) {
|
||||||
|
if err := walk(ref.gid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := walk(0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, g := range gids {
|
||||||
|
if err := walk(g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := make([]GlyphIndex, 0, len(needed))
|
||||||
|
for g := range needed {
|
||||||
|
order = append(order, g)
|
||||||
|
}
|
||||||
|
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubsetTrueType builds a minimal, valid TrueType sfnt carrying only the glyphs
|
||||||
|
// in gids plus glyph 0 and every component a composite among them references. The
|
||||||
|
// kept glyphs are renumbered compactly (glyph 0 stays 0); the returned oldToNew
|
||||||
|
// map gives each retained original glyph id its id in the subset, which a PDF
|
||||||
|
// CIDFontType2 embedder turns into a /CIDToGIDMap (CID = original id -> subset id)
|
||||||
|
// and a content-stream writer uses to re-address glyphs.
|
||||||
|
//
|
||||||
|
// The rebuilt font contains glyf, loca (long form), head, hhea, maxp, hmtx and a
|
||||||
|
// minimal cmap (enough to re-parse; a PDF addresses the subset by glyph id, not
|
||||||
|
// through the cmap), plus the TrueType instruction tables (cvt/fpgm/prep) when the
|
||||||
|
// original font carried them so hinted glyphs still render as designed. Composite
|
||||||
|
// component references are rewritten to the new glyph numbering.
|
||||||
|
//
|
||||||
|
// It returns an error for a font with no glyf table (a CFF/OpenType font — use
|
||||||
|
// SubsetCFF) or when a requested glyph id is out of range.
|
||||||
|
func (f *Font) SubsetTrueType(gids []GlyphIndex) (data []byte, oldToNew map[GlyphIndex]GlyphIndex, err error) {
|
||||||
|
if f.glyf == nil {
|
||||||
|
return nil, nil, errors.New("opentype: SubsetTrueType: font has no glyf table (not a TrueType font)")
|
||||||
|
}
|
||||||
|
kept, err := f.subsetClosure(gids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldToNew = make(map[GlyphIndex]GlyphIndex, len(kept))
|
||||||
|
for newGID, oldGID := range kept {
|
||||||
|
oldToNew[oldGID] = GlyphIndex(newGID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild glyf and a long-format loca in the new, compact glyph order,
|
||||||
|
// rewriting each composite's component ids to their subset ids.
|
||||||
|
blobs := make([][]byte, len(kept))
|
||||||
|
for newGID, oldGID := range kept {
|
||||||
|
blobs[newGID] = remapGlyph(f.rawGlyph(oldGID), oldToNew)
|
||||||
|
}
|
||||||
|
locaBytes, glyf := packGlyf(blobs)
|
||||||
|
|
||||||
|
tables := map[string][]byte{
|
||||||
|
"glyf": glyf,
|
||||||
|
"loca": locaBytes,
|
||||||
|
"head": f.subsetHead(),
|
||||||
|
"hhea": f.subsetHhea(len(kept)),
|
||||||
|
"maxp": f.subsetMaxp(len(kept)),
|
||||||
|
"hmtx": f.subsetHmtx(kept),
|
||||||
|
"cmap": minimalCmap(),
|
||||||
|
}
|
||||||
|
for _, opt := range []string{"cvt ", "fpgm", "prep"} {
|
||||||
|
if b, ok := f.tables[opt]; ok {
|
||||||
|
tables[opt] = append([]byte(nil), b...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildSFNT(versionTrueType, tables), oldToNew, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remapGlyph returns a copy of a raw glyph with every composite component id
|
||||||
|
// rewritten to its subset id via oldToNew. A simple or empty glyph is copied
|
||||||
|
// unchanged. A component id absent from oldToNew (which cannot happen for a glyph
|
||||||
|
// produced by subsetClosure, whose closure includes every component) is left as
|
||||||
|
// is, so the function never panics on unexpected input.
|
||||||
|
func remapGlyph(data []byte, oldToNew map[GlyphIndex]GlyphIndex) []byte {
|
||||||
|
refs := componentRefs(data)
|
||||||
|
out := append([]byte(nil), data...)
|
||||||
|
for _, ref := range refs {
|
||||||
|
if nb, ok := oldToNew[ref.gid]; ok {
|
||||||
|
binary.BigEndian.PutUint16(out[ref.offset:], uint16(nb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetHead clones the head table with checkSumAdjustment zeroed (buildSFNT
|
||||||
|
// recomputes it) and indexToLocFormat set to long, matching the long-format loca
|
||||||
|
// the subsetter always emits.
|
||||||
|
func (f *Font) subsetHead() []byte {
|
||||||
|
head := append([]byte(nil), f.tables["head"]...)
|
||||||
|
binary.BigEndian.PutUint32(head[8:], 0) // checkSumAdjustment: recomputed by buildSFNT
|
||||||
|
binary.BigEndian.PutUint16(head[50:], 1) // indexToLocFormat: long
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetHhea clones the hhea table with numberOfHMetrics set to the subset glyph
|
||||||
|
// count, matching the full-width hmtx subsetHmtx emits.
|
||||||
|
func (f *Font) subsetHhea(n int) []byte {
|
||||||
|
hhea := append([]byte(nil), f.tables["hhea"]...)
|
||||||
|
binary.BigEndian.PutUint16(hhea[34:], uint16(n))
|
||||||
|
return hhea
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetMaxp clones the maxp table with numGlyphs set to the subset glyph count.
|
||||||
|
// The other maxp limits (max points, max component depth, ...) remain valid upper
|
||||||
|
// bounds for a subset of the original glyphs, so they are carried over unchanged.
|
||||||
|
func (f *Font) subsetMaxp(n int) []byte {
|
||||||
|
maxp := append([]byte(nil), f.tables["maxp"]...)
|
||||||
|
binary.BigEndian.PutUint16(maxp[4:], uint16(n))
|
||||||
|
return maxp
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetHmtx builds an hmtx table for the subset glyph order: one (advanceWidth,
|
||||||
|
// leftSideBearing) pair per kept glyph, taken from the original per-glyph metrics.
|
||||||
|
// Emitting a full entry for every glyph (numberOfHMetrics == glyph count, set in
|
||||||
|
// subsetHhea) keeps the table trivially correct without the trailing shared-advance
|
||||||
|
// optimisation.
|
||||||
|
func (f *Font) subsetHmtx(kept []GlyphIndex) []byte {
|
||||||
|
out := make([]byte, 4*len(kept))
|
||||||
|
for i, oldGID := range kept {
|
||||||
|
binary.BigEndian.PutUint16(out[i*4:], uint16(f.advances[oldGID]))
|
||||||
|
binary.BigEndian.PutUint16(out[i*4+2:], uint16(int16(f.lsbs[oldGID])))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+406
@@ -0,0 +1,406 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file implements CFF (Type2 charstring) subsetting: rebuilding a 'CFF '
|
||||||
|
// table that keeps the outlines of a requested set of glyphs and replaces every
|
||||||
|
// other glyph's charstring with an empty one (a lone endchar). It is a real
|
||||||
|
// charstring subset — the CharStrings INDEX, the bulk of a CFF program, no longer
|
||||||
|
// carries the dropped glyphs' outlines — while glyph numbering is preserved, so a
|
||||||
|
// PDF embeds the result with an Identity CIDToGIDMap and the font's charset,
|
||||||
|
// encoding, Private DICT and subroutines all stay valid unchanged.
|
||||||
|
//
|
||||||
|
// Scope (documented, deliberate): the global and local subroutine INDEXes are
|
||||||
|
// retained whole rather than tree-shaken; CID-keyed CFF (a ROS/FDArray/FDSelect
|
||||||
|
// font) and CFF2 are rejected with an error rather than subset; and endchar-seac
|
||||||
|
// accent composition (a deprecated CFF feature) is not followed, so a caller
|
||||||
|
// embedding a seac-built accented glyph should also request its base and accent
|
||||||
|
// components. These edges keep the subsetter tractable and exact for the common
|
||||||
|
// name-keyed OTF, which is what PDF and EPUB embedders overwhelmingly handle.
|
||||||
|
|
||||||
|
// dictRawEntry is one operator of a CFF DICT together with the raw bytes of its
|
||||||
|
// operands and their decoded values. The raw bytes let a DICT be re-emitted
|
||||||
|
// verbatim except for the operators whose operands the subsetter rewrites (the
|
||||||
|
// offset operators of the Top DICT); copying them avoids decoding and re-encoding
|
||||||
|
// real-number operands (such as FontMatrix), which would not round-trip exactly.
|
||||||
|
// The decoded values let the subsetter read the offset and flag operators it acts
|
||||||
|
// on without a second parse pass.
|
||||||
|
type dictRawEntry struct {
|
||||||
|
operands []byte
|
||||||
|
values []float64
|
||||||
|
op int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDictRaw splits a CFF DICT into its operator entries, each carrying the raw
|
||||||
|
// operand bytes that preceded the operator and their decoded values. It reuses
|
||||||
|
// parseDictOperand for the per-operand byte lengths and values, so it accepts
|
||||||
|
// exactly the operands parseDict does.
|
||||||
|
func parseDictRaw(b []byte) ([]dictRawEntry, error) {
|
||||||
|
var entries []dictRawEntry
|
||||||
|
opStart := 0
|
||||||
|
var values []float64
|
||||||
|
i := 0
|
||||||
|
for i < len(b) {
|
||||||
|
b0 := b[i]
|
||||||
|
if b0 <= 21 {
|
||||||
|
operands := b[opStart:i]
|
||||||
|
op := int(b0)
|
||||||
|
i++
|
||||||
|
if b0 == 12 {
|
||||||
|
if i >= len(b) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff dict: truncated operator")
|
||||||
|
}
|
||||||
|
op = 1200 + int(b[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
entries = append(entries, dictRawEntry{operands: operands, values: values, op: op})
|
||||||
|
opStart = i
|
||||||
|
values = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, n, err := parseDictOperand(b, i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values = append(values, v)
|
||||||
|
i += n
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEntry returns the DICT entry for operator op, or nil when the operator is
|
||||||
|
// absent.
|
||||||
|
func findEntry(entries []dictRawEntry, op int) *dictRawEntry {
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].op == op {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCFFInt5 encodes v as a five-byte CFF DICT integer operand (marker 29 and
|
||||||
|
// a 32-bit big-endian value). A fixed five-byte width keeps the Top DICT the same
|
||||||
|
// size no matter what offset value it holds, which lets the layout be computed in
|
||||||
|
// one pass: the offsets an operand carries do not change the size of the DICT that
|
||||||
|
// carries them.
|
||||||
|
func encodeCFFInt5(v int) []byte {
|
||||||
|
return []byte{29, byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCFFOp encodes a DICT operator (a one-byte operator, or the escape byte 12
|
||||||
|
// followed by the second byte for a two-byte operator keyed as 1200+second).
|
||||||
|
func encodeCFFOp(op int) []byte {
|
||||||
|
if op >= 1200 {
|
||||||
|
return []byte{12, byte(op - 1200)}
|
||||||
|
}
|
||||||
|
return []byte{byte(op)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// topDictOffsets carries the rebuilt Top DICT's offset operands. charset is -1
|
||||||
|
// when the original charset is predefined (its operand is copied unchanged);
|
||||||
|
// otherwise it is the new absolute offset of the relocated charset. hasPrivate is
|
||||||
|
// false when the font has no Private DICT (no op-18 entry is emitted then).
|
||||||
|
type topDictOffsets struct {
|
||||||
|
charset int
|
||||||
|
charStrings int
|
||||||
|
privateSize int
|
||||||
|
privateOff int
|
||||||
|
hasPrivate bool
|
||||||
|
charsetIsPre bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeTopDict re-emits the Top DICT, copying every operator verbatim except the
|
||||||
|
// ones that carry absolute offsets: CharStrings (17) and Private (18) get the new
|
||||||
|
// offsets, charset (15) gets its new offset unless it is predefined, and Encoding
|
||||||
|
// (16) is forced to the predefined Standard encoding (0) since a custom encoding
|
||||||
|
// is neither relocated nor needed for embedding. All rewritten offset operands use
|
||||||
|
// the fixed five-byte form, so the encoded size does not depend on the offsets.
|
||||||
|
func encodeTopDict(entries []dictRawEntry, off topDictOffsets) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, e := range entries {
|
||||||
|
switch e.op {
|
||||||
|
case 15: // charset
|
||||||
|
if off.charsetIsPre {
|
||||||
|
out = append(out, e.operands...)
|
||||||
|
} else {
|
||||||
|
out = append(out, encodeCFFInt5(off.charset)...)
|
||||||
|
}
|
||||||
|
out = append(out, encodeCFFOp(15)...)
|
||||||
|
case 16: // Encoding -> Standard
|
||||||
|
out = append(out, encodeCFFInt5(0)...)
|
||||||
|
out = append(out, encodeCFFOp(16)...)
|
||||||
|
case 17: // CharStrings
|
||||||
|
out = append(out, encodeCFFInt5(off.charStrings)...)
|
||||||
|
out = append(out, encodeCFFOp(17)...)
|
||||||
|
case 18: // Private [size offset]
|
||||||
|
out = append(out, encodeCFFInt5(off.privateSize)...)
|
||||||
|
out = append(out, encodeCFFInt5(off.privateOff)...)
|
||||||
|
out = append(out, encodeCFFOp(18)...)
|
||||||
|
default:
|
||||||
|
out = append(out, e.operands...)
|
||||||
|
out = append(out, encodeCFFOp(e.op)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCFFIndex encodes items as a CFF INDEX (a count, an offset size, count+1
|
||||||
|
// relative offsets, then the concatenated items). An empty item list encodes as a
|
||||||
|
// count of zero, the shortest valid INDEX.
|
||||||
|
func writeCFFIndex(items [][]byte) []byte {
|
||||||
|
count := len(items)
|
||||||
|
out := []byte{byte(count >> 8), byte(count)}
|
||||||
|
if count == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
offs := make([]int, count+1)
|
||||||
|
offs[0] = 1
|
||||||
|
for i, it := range items {
|
||||||
|
offs[i+1] = offs[i] + len(it)
|
||||||
|
}
|
||||||
|
offSize := 1
|
||||||
|
for v := offs[count]; v > 0xFF; v >>= 8 {
|
||||||
|
offSize++
|
||||||
|
}
|
||||||
|
out = append(out, byte(offSize))
|
||||||
|
for _, o := range offs {
|
||||||
|
for b := offSize - 1; b >= 0; b-- {
|
||||||
|
out = append(out, byte(o>>(8*b)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffIndexEnd returns the byte offset just past the CFF INDEX beginning at pos.
|
||||||
|
func cffIndexEnd(data []byte, pos int) (int, error) {
|
||||||
|
r := &reader{b: data, pos: pos}
|
||||||
|
if _, err := parseIndex(r); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffCharsetLength returns the byte length of the (non-predefined) charset at off
|
||||||
|
// for a font of nGlyphs glyphs, decoding format 0, 1 or 2 exactly as parseCharset
|
||||||
|
// walks them.
|
||||||
|
func cffCharsetLength(data []byte, off, nGlyphs int) (int, error) {
|
||||||
|
if off > len(data) {
|
||||||
|
return 0, fmt.Errorf("opentype: cff charset offset out of range")
|
||||||
|
}
|
||||||
|
r := &reader{b: data, pos: off}
|
||||||
|
format := r.u8()
|
||||||
|
gid := 1
|
||||||
|
switch format {
|
||||||
|
case 0:
|
||||||
|
for gid < nGlyphs {
|
||||||
|
r.u16()
|
||||||
|
gid++
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
for gid < nGlyphs {
|
||||||
|
r.u16()
|
||||||
|
gid += int(r.u8()) + 1
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
for gid < nGlyphs {
|
||||||
|
r.u16()
|
||||||
|
gid += int(r.u16()) + 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("opentype: cff charset: unsupported format %d", format)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return 0, fmt.Errorf("opentype: cff charset: %w", r.err)
|
||||||
|
}
|
||||||
|
return r.pos - off, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cffPrivateBlob returns a copy of the Private DICT at [privOff, privOff+privSize)
|
||||||
|
// together with its Local Subr INDEX when it declares one, as a single contiguous
|
||||||
|
// blob. The blob preserves the byte distance between the Private DICT and its
|
||||||
|
// local subrs, so the DICT's (relative) Subrs offset stays valid after the blob is
|
||||||
|
// relocated as a whole.
|
||||||
|
func cffPrivateBlob(data []byte, privOff, privSize int) ([]byte, error) {
|
||||||
|
if privOff < 0 || privSize < 0 || privOff+privSize > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: Private DICT out of range")
|
||||||
|
}
|
||||||
|
priv, err := parseDict(data[privOff : privOff+privSize])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
soff, ok := dictInt(priv, 19, 0)
|
||||||
|
if !ok {
|
||||||
|
return append([]byte(nil), data[privOff:privOff+privSize]...), nil
|
||||||
|
}
|
||||||
|
subrAbs := privOff + soff
|
||||||
|
if subrAbs < 0 || subrAbs > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: Local Subrs offset out of range")
|
||||||
|
}
|
||||||
|
end, err := cffIndexEnd(data, subrAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append([]byte(nil), data[privOff:end]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubsetCFF builds a subset 'CFF ' table (the raw font program, as a PDF /FontFile3
|
||||||
|
// CIDFontType0C stream embeds it) that keeps the charstrings of the glyphs in gids
|
||||||
|
// plus glyph 0, replacing every other glyph's charstring with an empty one. Glyph
|
||||||
|
// numbering is preserved, so the font's charset, encoding, Private DICT and
|
||||||
|
// subroutines remain valid and a PDF embeds the result with an Identity CIDToGIDMap
|
||||||
|
// (no glyph-id remap is needed, unlike SubsetTrueType).
|
||||||
|
//
|
||||||
|
// It returns an error for a font with no CFF table (a TrueType font — use
|
||||||
|
// SubsetTrueType), for a CFF2 (variable) font, for a CID-keyed CFF (one with a ROS,
|
||||||
|
// FDArray or FDSelect operator), or when a requested glyph id is out of range. See
|
||||||
|
// the file-level comment for the retained-subroutine and endchar-seac scope notes.
|
||||||
|
func (f *Font) SubsetCFF(gids []GlyphIndex) ([]byte, error) {
|
||||||
|
if f.cff2 != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: SubsetCFF: CFF2 (variable) fonts are not supported; bake an instance first")
|
||||||
|
}
|
||||||
|
if f.cff == nil {
|
||||||
|
return nil, fmt.Errorf("opentype: SubsetCFF: font has no CFF table (not a CFF/OpenType font)")
|
||||||
|
}
|
||||||
|
// f.cff being non-nil means Parse decoded a 'CFF ' table, which it also
|
||||||
|
// retained in f.tables, so the lookup always succeeds here.
|
||||||
|
return subsetCFFTable(f.tables["CFF "], f.cff, gids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetCFFTable rebuilds the CFF table bytes: header and the Name, String and
|
||||||
|
// Global Subr INDEXes are copied verbatim; the Top DICT is re-emitted with new
|
||||||
|
// offsets; the CharStrings INDEX is rebuilt keeping the requested glyphs; and the
|
||||||
|
// (relocated) charset and Private+Local-Subr blobs are appended. The layout is
|
||||||
|
// header, Name INDEX, Top DICT INDEX, String INDEX, Global Subr INDEX, charset,
|
||||||
|
// CharStrings INDEX, Private+Local Subrs.
|
||||||
|
func subsetCFFTable(data []byte, cff *cffTable, gids []GlyphIndex) ([]byte, error) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff header: %w", errTruncated)
|
||||||
|
}
|
||||||
|
hdrSize := int(data[2])
|
||||||
|
if hdrSize < 4 || hdrSize > len(data) {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: bad hdrSize %d", hdrSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-walk the top-level INDEXes to recover their raw byte spans (Name, Top
|
||||||
|
// DICT, String, Global Subr), which are copied unchanged into the subset.
|
||||||
|
r := &reader{b: data, pos: hdrSize}
|
||||||
|
nameStart := r.pos
|
||||||
|
if _, err := parseIndex(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topStart := r.pos
|
||||||
|
topDicts, err := parseIndex(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(topDicts) < 1 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: empty Top DICT INDEX")
|
||||||
|
}
|
||||||
|
strStart := r.pos
|
||||||
|
if _, err := parseIndex(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gsubrStart := r.pos
|
||||||
|
if _, err := parseIndex(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gsubrEnd := r.pos
|
||||||
|
nameRaw := data[nameStart:topStart]
|
||||||
|
strRaw := data[strStart:gsubrStart]
|
||||||
|
gsubrRaw := data[gsubrStart:gsubrEnd]
|
||||||
|
|
||||||
|
entries, err := parseDictRaw(topDicts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// CID-keyed CFF (ROS/FDArray/FDSelect) is out of scope: its charset and
|
||||||
|
// FDSelect index the same glyph ids, and its Font DICTs carry their own
|
||||||
|
// Private offsets, none of which this preserve-numbering subsetter rewrites.
|
||||||
|
if findEntry(entries, 1230) != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: SubsetCFF: CID-keyed CFF is not supported")
|
||||||
|
}
|
||||||
|
if findEntry(entries, 1236) != nil {
|
||||||
|
return nil, fmt.Errorf("opentype: SubsetCFF: CID-keyed CFF (FDArray) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := len(cff.charStrings)
|
||||||
|
kept := map[GlyphIndex]bool{0: true}
|
||||||
|
for _, g := range gids {
|
||||||
|
if int(g) >= n {
|
||||||
|
return nil, fmt.Errorf("opentype: SubsetCFF: glyph index %d out of range", int(g))
|
||||||
|
}
|
||||||
|
kept[g] = true
|
||||||
|
}
|
||||||
|
items := make([][]byte, n)
|
||||||
|
for gid := 0; gid < n; gid++ {
|
||||||
|
if kept[GlyphIndex(gid)] {
|
||||||
|
items[gid] = cff.charStrings[gid]
|
||||||
|
} else {
|
||||||
|
items[gid] = []byte{14} // endchar: an empty glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newCharStrings := writeCFFIndex(items)
|
||||||
|
|
||||||
|
// Resolve the charset: predefined (offset <= 2) charsets are copied by operand
|
||||||
|
// and need no blob; a custom charset is relocated and its length measured.
|
||||||
|
var charsetBlob []byte
|
||||||
|
offsets := topDictOffsets{charset: -1, charsetIsPre: true}
|
||||||
|
if e := findEntry(entries, 15); e != nil && len(e.values) > 0 && int(e.values[0]) > 2 {
|
||||||
|
csOff := int(e.values[0])
|
||||||
|
csLen, err := cffCharsetLength(data, csOff, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
charsetBlob = append([]byte(nil), data[csOff:csOff+csLen]...)
|
||||||
|
offsets.charsetIsPre = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the Private DICT and its Local Subrs into one relocatable blob.
|
||||||
|
var privBlob []byte
|
||||||
|
if pv := findEntry(entries, 18); pv != nil {
|
||||||
|
if len(pv.values) < 2 {
|
||||||
|
return nil, fmt.Errorf("opentype: cff: bad Private DICT entry")
|
||||||
|
}
|
||||||
|
privSize, privOff := int(pv.values[0]), int(pv.values[1])
|
||||||
|
privBlob, err = cffPrivateBlob(data, privOff, privSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offsets.hasPrivate = true
|
||||||
|
offsets.privateSize = privSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure the Top DICT INDEX (fixed size thanks to five-byte offset operands),
|
||||||
|
// then compute every downstream offset and assemble.
|
||||||
|
topDictSize := len(writeCFFIndex([][]byte{encodeTopDict(entries, offsets)}))
|
||||||
|
|
||||||
|
pos := hdrSize + len(nameRaw) + topDictSize + len(strRaw) + len(gsubrRaw)
|
||||||
|
if !offsets.charsetIsPre {
|
||||||
|
offsets.charset = pos
|
||||||
|
pos += len(charsetBlob)
|
||||||
|
}
|
||||||
|
offsets.charStrings = pos
|
||||||
|
pos += len(newCharStrings)
|
||||||
|
if offsets.hasPrivate {
|
||||||
|
offsets.privateOff = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
topDict := encodeTopDict(entries, offsets)
|
||||||
|
out := append([]byte(nil), data[:hdrSize]...)
|
||||||
|
out = append(out, nameRaw...)
|
||||||
|
out = append(out, writeCFFIndex([][]byte{topDict})...)
|
||||||
|
out = append(out, strRaw...)
|
||||||
|
out = append(out, gsubrRaw...)
|
||||||
|
out = append(out, charsetBlob...)
|
||||||
|
out = append(out, newCharStrings...)
|
||||||
|
out = append(out, privBlob...)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// parseHead reads unitsPerEm and indexToLocFormat from the head table.
|
||||||
|
// The full head table is 54 bytes; indexToLocFormat sits at offset 50.
|
||||||
|
func (f *Font) parseHead(b []byte) error {
|
||||||
|
if len(b) < 54 {
|
||||||
|
return fmt.Errorf("opentype: head table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.unitsPerEm = int(be16(b[18:]))
|
||||||
|
if f.unitsPerEm == 0 {
|
||||||
|
return fmt.Errorf("opentype: head table: unitsPerEm is zero")
|
||||||
|
}
|
||||||
|
f.macStyle = be16(b[44:])
|
||||||
|
f.xMin = int(sbe16(b[36:]))
|
||||||
|
f.yMin = int(sbe16(b[38:]))
|
||||||
|
f.xMax = int(sbe16(b[40:]))
|
||||||
|
f.yMax = int(sbe16(b[42:]))
|
||||||
|
f.indexToLocFormat = int(sbe16(b[50:]))
|
||||||
|
if f.indexToLocFormat != 0 && f.indexToLocFormat != 1 {
|
||||||
|
return fmt.Errorf("opentype: head table: bad indexToLocFormat %d", f.indexToLocFormat)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMaxp reads numGlyphs from the maxp table (offset 4).
|
||||||
|
func (f *Font) parseMaxp(b []byte) error {
|
||||||
|
if len(b) < 6 {
|
||||||
|
return fmt.Errorf("opentype: maxp table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.numGlyphs = int(be16(b[4:]))
|
||||||
|
if f.numGlyphs == 0 {
|
||||||
|
return fmt.Errorf("opentype: maxp table: zero glyphs")
|
||||||
|
}
|
||||||
|
// Version-1.0 maxp additionally carries the interpreter limits used by the
|
||||||
|
// hinting VM. A shorter (version-0.5) table leaves conservative defaults.
|
||||||
|
f.maxTwilightPts = 16
|
||||||
|
f.maxStorage = 64
|
||||||
|
f.maxFunctionDefs = 64
|
||||||
|
f.maxStackElements = 256
|
||||||
|
if len(b) >= 26 {
|
||||||
|
f.maxTwilightPts = int(be16(b[16:]))
|
||||||
|
f.maxStorage = int(be16(b[18:]))
|
||||||
|
f.maxFunctionDefs = int(be16(b[20:]))
|
||||||
|
f.maxStackElements = int(be16(b[24:]))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHhea reads the vertical metrics and numberOfHMetrics from hhea.
|
||||||
|
// ascender/descender/lineGap are int16 at offsets 4/6/8; numberOfHMetrics is
|
||||||
|
// the uint16 at offset 34.
|
||||||
|
func (f *Font) parseHhea(b []byte) error {
|
||||||
|
if len(b) < 36 {
|
||||||
|
return fmt.Errorf("opentype: hhea table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.ascender = int(sbe16(b[4:]))
|
||||||
|
f.descender = int(sbe16(b[6:]))
|
||||||
|
f.lineGap = int(sbe16(b[8:]))
|
||||||
|
f.numberOfHMetrics = int(be16(b[34:]))
|
||||||
|
if f.numberOfHMetrics == 0 || f.numberOfHMetrics > f.numGlyphs {
|
||||||
|
return fmt.Errorf("opentype: hhea table: bad numberOfHMetrics %d", f.numberOfHMetrics)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberOfHMetrics is kept only for the span of parseHhea→parseHmtx.
|
||||||
|
// It is declared on Font to pass the value between the two parsers.
|
||||||
|
|
||||||
|
// parseHmtx expands the hmtx table into per-glyph advanceWidth and left side
|
||||||
|
// bearing slices. The first numberOfHMetrics entries are (advance, lsb) pairs;
|
||||||
|
// the trailing glyphs share the last entry's advance and carry their own lsb.
|
||||||
|
func (f *Font) parseHmtx(b []byte) error {
|
||||||
|
n := f.numberOfHMetrics
|
||||||
|
need := 4*n + 2*(f.numGlyphs-n)
|
||||||
|
if len(b) < need {
|
||||||
|
return fmt.Errorf("opentype: hmtx table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.advances = make([]int, f.numGlyphs)
|
||||||
|
f.lsbs = make([]int, f.numGlyphs)
|
||||||
|
lastAdvance := 0
|
||||||
|
for i := 0; i < f.numGlyphs; i++ {
|
||||||
|
if i < n {
|
||||||
|
lastAdvance = int(be16(b[i*4:]))
|
||||||
|
f.advances[i] = lastAdvance
|
||||||
|
f.lsbs[i] = int(sbe16(b[i*4+2:]))
|
||||||
|
} else {
|
||||||
|
f.advances[i] = lastAdvance
|
||||||
|
f.lsbs[i] = int(sbe16(b[4*n+2*(i-n):]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLoca decodes the loca table into numGlyphs+1 glyph offsets into glyf.
|
||||||
|
// Short format stores uint16 half-offsets (doubled); long format stores
|
||||||
|
// uint32 offsets directly, selected by head.indexToLocFormat.
|
||||||
|
func (f *Font) parseLoca(b []byte) error {
|
||||||
|
count := f.numGlyphs + 1
|
||||||
|
f.loca = make([]uint32, count)
|
||||||
|
if f.indexToLocFormat == 0 {
|
||||||
|
if len(b) < count*2 {
|
||||||
|
return fmt.Errorf("opentype: loca table (short): %w", errTruncated)
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
f.loca[i] = uint32(be16(b[i*2:])) * 2
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(b) < count*4 {
|
||||||
|
return fmt.Errorf("opentype: loca table (long): %w", errTruncated)
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
f.loca[i] = be32(b[i*4:])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+467
@@ -0,0 +1,467 @@
|
|||||||
|
// Copyright (c) 2026, the go-opentype authors
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// type1Font is a PostScript Type 1 font program: a dictionary of charstrings
|
||||||
|
// addressed by name, the subroutines they call, and the encoding the program
|
||||||
|
// was written with. It is the format PDF calls FontFile, and the one every
|
||||||
|
// PostScript printer of the nineteen-nineties spoke.
|
||||||
|
type type1Font struct {
|
||||||
|
// names is every glyph, in the order they were read, which is the order
|
||||||
|
// their indices are handed out in. .notdef is put first when the font has
|
||||||
|
// one, so that glyph zero means what it means everywhere else.
|
||||||
|
names []string
|
||||||
|
charstrings map[string][]byte
|
||||||
|
byName map[string]GlyphIndex
|
||||||
|
subrs [][]byte
|
||||||
|
// encoding is what the program itself says a byte stands for; nil means
|
||||||
|
// it said to use the standard one.
|
||||||
|
encoding map[byte]string
|
||||||
|
fontMatrix [6]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three constants the eexec cipher is built on, and the two keys: one for
|
||||||
|
// the program's private half, one for each charstring inside it.
|
||||||
|
const (
|
||||||
|
eexecC1 = 52845
|
||||||
|
eexecC2 = 22719
|
||||||
|
eexecKey = 55665
|
||||||
|
charstringR = 4330
|
||||||
|
defaultLenIV = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseType1 decodes a PostScript Type 1 font program — what a PDF carries as
|
||||||
|
// a FontFile, and what a .pfb or .pfa file holds. Such a program addresses its
|
||||||
|
// glyphs by name and carries an encoding of its own, so the result answers
|
||||||
|
// [Font.GlyphIndexByName] and [Font.GlyphIndexByCode] and reports no character
|
||||||
|
// map.
|
||||||
|
//
|
||||||
|
// The bytes are not retained: a Type 1 program is decrypted on the way in, so
|
||||||
|
// what is kept is the plaintext.
|
||||||
|
func ParseType1(b []byte) (*Font, error) {
|
||||||
|
clear, enc, err := type1Halves(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
private := eexecDecrypt(enc, eexecKey, 4)
|
||||||
|
if len(private) == 0 {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: the private half is empty")
|
||||||
|
}
|
||||||
|
t1 := &type1Font{
|
||||||
|
charstrings: map[string][]byte{},
|
||||||
|
byName: map[string]GlyphIndex{},
|
||||||
|
fontMatrix: defaultFontMatrix,
|
||||||
|
}
|
||||||
|
t1.readClear(clear)
|
||||||
|
if err := t1.readPrivate(private); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(t1.names) == 0 {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: the font has no glyphs")
|
||||||
|
}
|
||||||
|
f := &Font{
|
||||||
|
t1: t1,
|
||||||
|
numGlyphs: len(t1.names),
|
||||||
|
unitsPerEm: unitsPerEmOf(t1.fontMatrix),
|
||||||
|
numberOfHMetrics: len(t1.names),
|
||||||
|
glyphNames: t1.byName,
|
||||||
|
}
|
||||||
|
f.ascender = f.unitsPerEm
|
||||||
|
f.advances, f.lsbs = t1.metrics()
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// type1Halves separates the program's readable half from its encrypted one,
|
||||||
|
// whichever container they arrived in: the segments of a .pfb file, the hex of
|
||||||
|
// a .pfa one, or the plain concatenation a PDF stream holds.
|
||||||
|
func type1Halves(b []byte) (clear, enc []byte, err error) {
|
||||||
|
if len(b) >= 2 && b[0] == 0x80 {
|
||||||
|
if b, err = joinPFB(b); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i := bytes.Index(b, []byte("eexec"))
|
||||||
|
if i < 0 {
|
||||||
|
return nil, nil, fmt.Errorf("opentype: type1: the program has no eexec section")
|
||||||
|
}
|
||||||
|
clear = b[:i]
|
||||||
|
rest := b[i+len("eexec"):]
|
||||||
|
for len(rest) > 0 && isPSSpace(rest[0]) {
|
||||||
|
rest = rest[1:]
|
||||||
|
}
|
||||||
|
if isHexSection(rest) {
|
||||||
|
return clear, unhex(rest), nil
|
||||||
|
}
|
||||||
|
return clear, rest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinPFB concatenates the segments of a .pfb file, which wraps each half in a
|
||||||
|
// six-byte header rather than running them together.
|
||||||
|
func joinPFB(b []byte) ([]byte, error) {
|
||||||
|
var out []byte
|
||||||
|
for len(b) >= 2 && b[0] == 0x80 {
|
||||||
|
switch b[1] {
|
||||||
|
case 3:
|
||||||
|
return out, nil
|
||||||
|
case 1, 2:
|
||||||
|
if len(b) < 6 {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: a pfb segment header is cut short")
|
||||||
|
}
|
||||||
|
n := int(b[2]) | int(b[3])<<8 | int(b[4])<<16 | int(b[5])<<24
|
||||||
|
if n < 0 || 6+n > len(b) {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: a pfb segment runs past the end")
|
||||||
|
}
|
||||||
|
out = append(out, b[6:6+n]...)
|
||||||
|
b = b[6+n:]
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("opentype: type1: unknown pfb segment %d", b[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexLookahead is how many bytes must all be hexadecimal digits before the
|
||||||
|
// section is read as hexadecimal. The specification says four, which is not
|
||||||
|
// enough: a binary section begins with four random bytes, and four random
|
||||||
|
// bytes are all hexadecimal digits about once in twenty thousand times — often
|
||||||
|
// enough to happen across a corpus, and when it does the whole font is lost.
|
||||||
|
// Sixteen makes that impossible while still holding for every real one, whose
|
||||||
|
// hexadecimal runs are lines long.
|
||||||
|
const hexLookahead = 16
|
||||||
|
|
||||||
|
// isHexSection reports whether the encrypted half was written as hexadecimal,
|
||||||
|
// which is what a .pfa file does.
|
||||||
|
func isHexSection(b []byte) bool {
|
||||||
|
n := 0
|
||||||
|
for _, c := range b {
|
||||||
|
if isPSSpace(c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isHexDigit(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n++; n == hexLookahead {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexDigit(c byte) bool {
|
||||||
|
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPSSpace(c byte) bool {
|
||||||
|
return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// unhex reads a hexadecimal section, ignoring whatever whitespace it was laid
|
||||||
|
// out with.
|
||||||
|
func unhex(b []byte) []byte {
|
||||||
|
out := make([]byte, 0, len(b)/2)
|
||||||
|
var hi byte
|
||||||
|
have := false
|
||||||
|
for _, c := range b {
|
||||||
|
if !isHexDigit(c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := hexVal(c)
|
||||||
|
if !have {
|
||||||
|
hi, have = v, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, hi<<4|v)
|
||||||
|
have = false
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexVal(c byte) byte {
|
||||||
|
switch {
|
||||||
|
case c <= '9':
|
||||||
|
return c - '0'
|
||||||
|
case c <= 'F':
|
||||||
|
return c - 'A' + 10
|
||||||
|
default:
|
||||||
|
return c - 'a' + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eexecDecrypt undoes the cipher a Type 1 program is written in, throwing away
|
||||||
|
// the leading bytes that exist only to randomise it.
|
||||||
|
func eexecDecrypt(data []byte, key uint16, skip int) []byte {
|
||||||
|
r := key
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
for i, c := range data {
|
||||||
|
out[i] = c ^ byte(r>>8)
|
||||||
|
r = (uint16(c)+r)*eexecC1 + eexecC2
|
||||||
|
}
|
||||||
|
if len(out) < skip {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out[skip:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// readClear takes what the readable half says: the font's matrix, and the
|
||||||
|
// encoding it was written with.
|
||||||
|
func (t *type1Font) readClear(b []byte) {
|
||||||
|
if m, ok := readMatrix(b); ok {
|
||||||
|
t.fontMatrix = m
|
||||||
|
}
|
||||||
|
t.encoding = readType1Encoding(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMatrix reads /FontMatrix, which says how big the program's own units are.
|
||||||
|
func readMatrix(b []byte) ([6]float64, bool) {
|
||||||
|
var m [6]float64
|
||||||
|
i := bytes.Index(b, []byte("/FontMatrix"))
|
||||||
|
if i < 0 {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
open := bytes.IndexByte(b[i:], '[')
|
||||||
|
if open < 0 {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
close := bytes.IndexByte(b[i+open:], ']')
|
||||||
|
if close < 0 {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
fields := bytes.Fields(b[i+open+1 : i+open+close])
|
||||||
|
if len(fields) < 6 {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
for k := 0; k < 6; k++ {
|
||||||
|
v, err := strconv.ParseFloat(string(fields[k]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
if m[0] == 0 {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readType1Encoding reads the /Encoding the program was written with: either a
|
||||||
|
// name saying to use the standard one, or an array built up by putting a name
|
||||||
|
// at a code at a time.
|
||||||
|
func readType1Encoding(b []byte) map[byte]string {
|
||||||
|
i := bytes.Index(b, []byte("/Encoding"))
|
||||||
|
if i < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := b[i+len("/Encoding"):]
|
||||||
|
// Whatever comes first decides: a name, or an array being built.
|
||||||
|
if j := bytes.Index(rest, []byte("StandardEncoding")); j >= 0 && j < 40 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[byte]string{}
|
||||||
|
for {
|
||||||
|
j := bytes.Index(rest, []byte("dup "))
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest = rest[j+4:]
|
||||||
|
fields := bytes.Fields(rest)
|
||||||
|
if len(fields) < 3 || len(fields[1]) < 2 || fields[1][0] != '/' || string(fields[2]) != "put" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code, err := strconv.Atoi(string(fields[0]))
|
||||||
|
if err != nil || code < 0 || code > 255 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[byte(code)] = string(fields[1][1:])
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPrivate takes what the encrypted half holds: how much of each charstring
|
||||||
|
// is padding, the subroutines, and the charstrings themselves.
|
||||||
|
func (t *type1Font) readPrivate(b []byte) error {
|
||||||
|
lenIV := defaultLenIV
|
||||||
|
if i := bytes.Index(b, []byte("/lenIV")); i >= 0 {
|
||||||
|
if fields := bytes.Fields(b[i+len("/lenIV"):]); len(fields) > 0 {
|
||||||
|
if v, err := strconv.Atoi(string(fields[0])); err == nil && v >= 0 && v < 16 {
|
||||||
|
lenIV = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.readSubrs(b, lenIV)
|
||||||
|
return t.readCharstrings(b, lenIV)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSubrs reads the /Subrs array: a numbered list of shared charstrings.
|
||||||
|
func (t *type1Font) readSubrs(b []byte, lenIV int) {
|
||||||
|
i := bytes.Index(b, []byte("/Subrs"))
|
||||||
|
if i < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rest := b[i+len("/Subrs"):]
|
||||||
|
fields := bytes.Fields(rest)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(string(fields[0]))
|
||||||
|
if err != nil || n < 0 || n > 65535 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.subrs = make([][]byte, n)
|
||||||
|
for k := 0; k < n; k++ {
|
||||||
|
j := bytes.Index(rest, []byte("dup "))
|
||||||
|
if j < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rest = rest[j+4:]
|
||||||
|
idx, data, next, ok := readBinaryEntry(rest, lenIV)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if num, err := strconv.Atoi(string(idx)); err == nil && num >= 0 && num < n {
|
||||||
|
t.subrs[num] = data
|
||||||
|
}
|
||||||
|
rest = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCharstrings reads the /CharStrings dictionary: a glyph name and its
|
||||||
|
// charstring, over and over.
|
||||||
|
func (t *type1Font) readCharstrings(b []byte, lenIV int) error {
|
||||||
|
i := bytes.Index(b, []byte("/CharStrings"))
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("opentype: type1: the program has no CharStrings")
|
||||||
|
}
|
||||||
|
rest := b[i+len("/CharStrings"):]
|
||||||
|
// Past the dictionary's own preamble, every entry begins with a name.
|
||||||
|
if j := bytes.Index(rest, []byte("begin")); j >= 0 {
|
||||||
|
rest = rest[j+len("begin"):]
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
j := bytes.IndexByte(rest, '/')
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest = rest[j+1:]
|
||||||
|
raw, data, next, ok := readBinaryEntry(rest, lenIV)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := string(raw)
|
||||||
|
if _, seen := t.charstrings[name]; !seen && name != "" {
|
||||||
|
t.charstrings[name] = data
|
||||||
|
t.names = append(t.names, name)
|
||||||
|
}
|
||||||
|
rest = next
|
||||||
|
}
|
||||||
|
t.order()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBinaryEntry reads "<key> <length> RD <length bytes> ..." — the shape
|
||||||
|
// both a subroutine and a charstring arrive in. The operator that introduces
|
||||||
|
// the bytes is spelt RD by some programs and -| by others, and neither is a
|
||||||
|
// keyword worth insisting on: what matters is that a number is followed by
|
||||||
|
// something that is not a number, and then that many bytes.
|
||||||
|
func readBinaryEntry(b []byte, lenIV int) (key, data []byte, rest []byte, ok bool) {
|
||||||
|
name, after := psToken(b)
|
||||||
|
if name == nil {
|
||||||
|
return nil, nil, nil, false
|
||||||
|
}
|
||||||
|
lengthTok, after := psToken(after)
|
||||||
|
n, err := strconv.Atoi(string(lengthTok))
|
||||||
|
if err != nil || n < 0 || n > len(after) {
|
||||||
|
return nil, nil, nil, false
|
||||||
|
}
|
||||||
|
_, after = psToken(after) // RD, -|, or whatever this program calls it
|
||||||
|
if len(after) < 1+n {
|
||||||
|
return nil, nil, nil, false
|
||||||
|
}
|
||||||
|
// Exactly one space separates the operator from the bytes.
|
||||||
|
raw := after[1 : 1+n]
|
||||||
|
return name, eexecDecrypt(raw, charstringR, lenIV), after[1+n:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// psToken reads one whitespace-delimited token.
|
||||||
|
func psToken(b []byte) (tok, rest []byte) {
|
||||||
|
i := 0
|
||||||
|
for i < len(b) && isPSSpace(b[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
for i < len(b) && !isPSSpace(b[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if start == i {
|
||||||
|
return nil, b[i:]
|
||||||
|
}
|
||||||
|
return b[start:i], b[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// order puts .notdef first, so that glyph zero means what it means in every
|
||||||
|
// other format, and indexes the rest where they fell.
|
||||||
|
func (t *type1Font) order() {
|
||||||
|
for i, n := range t.names {
|
||||||
|
if n == ".notdef" && i != 0 {
|
||||||
|
t.names = append([]string{n}, append(t.names[:i:i], t.names[i+1:]...)...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, n := range t.names {
|
||||||
|
t.byName[n] = GlyphIndex(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics is what each glyph's charstring says it is wide, which for a Type 1
|
||||||
|
// program is the operand of its very first operator.
|
||||||
|
func (t *type1Font) metrics() (advances, lsbs []int) {
|
||||||
|
advances = make([]int, len(t.names))
|
||||||
|
lsbs = make([]int, len(t.names))
|
||||||
|
for i, name := range t.names {
|
||||||
|
sb, w, ok := type1Width(t.charstrings[name])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
advances[i] = int(math.Round(w))
|
||||||
|
lsbs[i] = int(math.Round(sb))
|
||||||
|
}
|
||||||
|
return advances, lsbs
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphByCode maps a byte through the encoding the program was written with.
|
||||||
|
func (t *type1Font) glyphByCode(code byte) (GlyphIndex, bool) {
|
||||||
|
name, ok := t.encoding[code]
|
||||||
|
if !ok {
|
||||||
|
if t.encoding != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sid, in := standardEncodingSID(int(code))
|
||||||
|
if !in || int(sid) >= nStdStrings {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
name = cffStandardStrings[sid]
|
||||||
|
}
|
||||||
|
gid, ok := t.byName[name]
|
||||||
|
return gid, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphName is what the program calls a glyph.
|
||||||
|
func (t *type1Font) glyphName(gid int) (string, bool) {
|
||||||
|
if gid < 0 || gid >= len(t.names) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return t.names[gid], true
|
||||||
|
}
|
||||||
+493
@@ -0,0 +1,493 @@
|
|||||||
|
// Copyright (c) 2026, the go-opentype authors
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// The Type 1 charstring operators. They are not the Type 2 ones: the two
|
||||||
|
// formats share a lineage and very little else.
|
||||||
|
const (
|
||||||
|
t1HStem = 1
|
||||||
|
t1VStem = 3
|
||||||
|
t1VMoveTo = 4
|
||||||
|
t1RLineTo = 5
|
||||||
|
t1HLineTo = 6
|
||||||
|
t1VLineTo = 7
|
||||||
|
t1RRCurveTo = 8
|
||||||
|
t1ClosePath = 9
|
||||||
|
t1CallSubr = 10
|
||||||
|
t1Return = 11
|
||||||
|
t1Escape = 12
|
||||||
|
t1HSbw = 13
|
||||||
|
t1EndChar = 14
|
||||||
|
t1RMoveTo = 21
|
||||||
|
t1HMoveTo = 22
|
||||||
|
t1VHCurveTo = 30
|
||||||
|
t1HVCurveTo = 31
|
||||||
|
)
|
||||||
|
|
||||||
|
// The escaped operators, reached through operator 12.
|
||||||
|
const (
|
||||||
|
t1DotSection = 0
|
||||||
|
t1VStem3 = 1
|
||||||
|
t1HStem3 = 2
|
||||||
|
t1Seac = 6
|
||||||
|
t1Sbw = 7
|
||||||
|
t1Div = 12
|
||||||
|
t1CallOtherSubr = 16
|
||||||
|
t1Pop = 17
|
||||||
|
t1SetCurrentPoint = 33
|
||||||
|
maxT1Depth = 30
|
||||||
|
maxT1Stack = 48
|
||||||
|
maxT1PostScriptSize = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// t1machine runs one Type 1 charstring.
|
||||||
|
type t1machine struct {
|
||||||
|
f *type1Font
|
||||||
|
stack []float64
|
||||||
|
ps []float64 // the interpreter's other stack, which othersubrs use
|
||||||
|
contours []contour
|
||||||
|
cur contour
|
||||||
|
x, y float64
|
||||||
|
sbx, sby float64
|
||||||
|
width float64
|
||||||
|
haveWidth bool
|
||||||
|
// flex collects the seven points an othersubr-driven curve is built from;
|
||||||
|
// while it is running, a move records a point instead of starting a
|
||||||
|
// contour.
|
||||||
|
inFlex bool
|
||||||
|
flexPts []outlinePoint
|
||||||
|
seacDepth int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// outline draws a glyph of a Type 1 program.
|
||||||
|
func (t *type1Font) outline(gid int) ([]contour, error) {
|
||||||
|
name, ok := t.glyphName(gid)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: glyph index %d out of range", gid)
|
||||||
|
}
|
||||||
|
return t.outlineByName(name, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// outlineByName draws the glyph a name stands for.
|
||||||
|
func (t *type1Font) outlineByName(name string, seacDepth int) ([]contour, error) {
|
||||||
|
cs, ok := t.charstrings[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: no glyph called %q", name)
|
||||||
|
}
|
||||||
|
m := &t1machine{f: t, seacDepth: seacDepth}
|
||||||
|
if err := m.run(cs, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.finishContour()
|
||||||
|
return m.contours, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// type1Width reads what a charstring says its glyph is wide, which is the
|
||||||
|
// operand of hsbw or sbw — always the first operator of a Type 1 glyph.
|
||||||
|
func type1Width(cs []byte) (sb, w float64, ok bool) {
|
||||||
|
var stack []float64
|
||||||
|
for i := 0; i < len(cs); {
|
||||||
|
v, next, isNum, valid := t1Operand(cs, i)
|
||||||
|
if !valid {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if isNum {
|
||||||
|
stack = append(stack, v)
|
||||||
|
i = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch cs[i] {
|
||||||
|
case t1HSbw:
|
||||||
|
if len(stack) < 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return stack[0], stack[1], true
|
||||||
|
case t1Escape:
|
||||||
|
if i+1 < len(cs) && cs[i+1] == t1Sbw {
|
||||||
|
if len(stack) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return stack[0], stack[2], true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
default:
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// t1Operand reads one charstring byte, saying whether it was a number and
|
||||||
|
// where the next one is.
|
||||||
|
func t1Operand(cs []byte, i int) (v float64, next int, isNum, ok bool) {
|
||||||
|
b := cs[i]
|
||||||
|
switch {
|
||||||
|
case b >= 32 && b <= 246:
|
||||||
|
return float64(int(b) - 139), i + 1, true, true
|
||||||
|
case b >= 247 && b <= 250:
|
||||||
|
if i+1 >= len(cs) {
|
||||||
|
return 0, 0, false, false
|
||||||
|
}
|
||||||
|
return float64((int(b)-247)*256 + int(cs[i+1]) + 108), i + 2, true, true
|
||||||
|
case b >= 251 && b <= 254:
|
||||||
|
if i+1 >= len(cs) {
|
||||||
|
return 0, 0, false, false
|
||||||
|
}
|
||||||
|
return float64(-(int(b)-251)*256 - int(cs[i+1]) - 108), i + 2, true, true
|
||||||
|
case b == 255:
|
||||||
|
if i+4 >= len(cs) {
|
||||||
|
return 0, 0, false, false
|
||||||
|
}
|
||||||
|
n := int32(uint32(cs[i+1])<<24 | uint32(cs[i+2])<<16 | uint32(cs[i+3])<<8 | uint32(cs[i+4]))
|
||||||
|
return float64(n), i + 5, true, true
|
||||||
|
}
|
||||||
|
return 0, i, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes a charstring or a subroutine.
|
||||||
|
func (m *t1machine) run(cs []byte, depth int) error {
|
||||||
|
if depth > maxT1Depth {
|
||||||
|
return fmt.Errorf("opentype: type1: subroutine nesting too deep")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(cs); {
|
||||||
|
v, next, isNum, ok := t1Operand(cs, i)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("opentype: type1: a charstring operand runs past the end")
|
||||||
|
}
|
||||||
|
if isNum {
|
||||||
|
if len(m.stack) >= maxT1Stack {
|
||||||
|
return fmt.Errorf("opentype: type1: too many operands")
|
||||||
|
}
|
||||||
|
m.stack = append(m.stack, v)
|
||||||
|
i = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
op := cs[i]
|
||||||
|
i++
|
||||||
|
if op == t1Escape {
|
||||||
|
if i >= len(cs) {
|
||||||
|
return fmt.Errorf("opentype: type1: a charstring ends in an escape")
|
||||||
|
}
|
||||||
|
esc := cs[i]
|
||||||
|
i++
|
||||||
|
stop, err := m.escaped(esc, depth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stop, err := m.operator(op, depth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operator runs one plain operator, reporting whether the charstring is over.
|
||||||
|
func (m *t1machine) operator(op byte, depth int) (bool, error) {
|
||||||
|
s := m.stack
|
||||||
|
switch op {
|
||||||
|
case t1HStem, t1VStem:
|
||||||
|
m.clear()
|
||||||
|
case t1HSbw:
|
||||||
|
if len(s) >= 2 {
|
||||||
|
m.sbx, m.width, m.haveWidth = s[0], s[1], true
|
||||||
|
m.x, m.y = m.sbx, 0
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1RMoveTo:
|
||||||
|
if len(s) >= 2 {
|
||||||
|
m.moveTo(s[len(s)-2], s[len(s)-1])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1HMoveTo:
|
||||||
|
if len(s) >= 1 {
|
||||||
|
m.moveTo(s[len(s)-1], 0)
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1VMoveTo:
|
||||||
|
if len(s) >= 1 {
|
||||||
|
m.moveTo(0, s[len(s)-1])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1RLineTo:
|
||||||
|
if len(s) >= 2 {
|
||||||
|
m.lineTo(s[0], s[1])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1HLineTo:
|
||||||
|
if len(s) >= 1 {
|
||||||
|
m.lineTo(s[0], 0)
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1VLineTo:
|
||||||
|
if len(s) >= 1 {
|
||||||
|
m.lineTo(0, s[0])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1RRCurveTo:
|
||||||
|
if len(s) >= 6 {
|
||||||
|
m.rrcurveTo(s[0], s[1], s[2], s[3], s[4], s[5])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1HVCurveTo:
|
||||||
|
if len(s) >= 4 {
|
||||||
|
m.rrcurveTo(s[0], 0, s[1], s[2], 0, s[3])
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1VHCurveTo:
|
||||||
|
if len(s) >= 4 {
|
||||||
|
m.rrcurveTo(0, s[0], s[1], s[2], s[3], 0)
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1ClosePath:
|
||||||
|
m.closePath()
|
||||||
|
m.clear()
|
||||||
|
case t1CallSubr:
|
||||||
|
return false, m.callSubr(depth)
|
||||||
|
case t1Return:
|
||||||
|
return true, nil
|
||||||
|
case t1EndChar:
|
||||||
|
m.done = true
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
// An operator this format has not got: the rest of the charstring
|
||||||
|
// cannot be trusted, but what has been drawn so far can.
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// escaped runs one of the operators reached through the escape byte.
|
||||||
|
func (m *t1machine) escaped(esc byte, depth int) (bool, error) {
|
||||||
|
s := m.stack
|
||||||
|
switch esc {
|
||||||
|
case t1DotSection, t1VStem3, t1HStem3:
|
||||||
|
m.clear()
|
||||||
|
case t1Sbw:
|
||||||
|
if len(s) >= 4 {
|
||||||
|
m.sbx, m.sby, m.width, m.haveWidth = s[0], s[1], s[2], true
|
||||||
|
m.x, m.y = m.sbx, m.sby
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
case t1Div:
|
||||||
|
if len(s) >= 2 && s[len(s)-1] != 0 {
|
||||||
|
m.stack = append(s[:len(s)-2], s[len(s)-2]/s[len(s)-1])
|
||||||
|
}
|
||||||
|
case t1Seac:
|
||||||
|
return true, m.seac()
|
||||||
|
case t1CallOtherSubr:
|
||||||
|
return false, m.callOtherSubr()
|
||||||
|
case t1Pop:
|
||||||
|
if len(m.ps) > 0 {
|
||||||
|
m.stack = append(m.stack, m.ps[len(m.ps)-1])
|
||||||
|
m.ps = m.ps[:len(m.ps)-1]
|
||||||
|
} else {
|
||||||
|
m.stack = append(m.stack, 0)
|
||||||
|
}
|
||||||
|
case t1SetCurrentPoint:
|
||||||
|
if len(s) >= 2 {
|
||||||
|
m.x, m.y = s[0], s[1]
|
||||||
|
}
|
||||||
|
m.clear()
|
||||||
|
default:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callSubr runs a numbered subroutine.
|
||||||
|
func (m *t1machine) callSubr(depth int) error {
|
||||||
|
if len(m.stack) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := int(m.stack[len(m.stack)-1])
|
||||||
|
m.stack = m.stack[:len(m.stack)-1]
|
||||||
|
if n < 0 || n >= len(m.f.subrs) || m.f.subrs[n] == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.run(m.f.subrs[n], depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callOtherSubr handles the four subroutines a Type 1 program calls out to the
|
||||||
|
// interpreter for. Numbers 0 to 2 build a flex — a curve so shallow it is
|
||||||
|
// drawn as one — and number 3 asks which hints to use, which nothing here
|
||||||
|
// needs. Anything else is answered by handing its arguments straight back, so
|
||||||
|
// that a program using an othersubr of its own carries on rather than stops.
|
||||||
|
func (m *t1machine) callOtherSubr() error {
|
||||||
|
if len(m.stack) < 2 {
|
||||||
|
m.clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
which := int(m.stack[len(m.stack)-1])
|
||||||
|
n := int(m.stack[len(m.stack)-2])
|
||||||
|
m.stack = m.stack[:len(m.stack)-2]
|
||||||
|
if n < 0 || n > len(m.stack) {
|
||||||
|
m.clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := m.stack[len(m.stack)-n:]
|
||||||
|
m.stack = m.stack[:len(m.stack)-n]
|
||||||
|
|
||||||
|
switch which {
|
||||||
|
case 0: // the flex is over: draw the two curves its seven points describe
|
||||||
|
m.endFlex()
|
||||||
|
// The convention is that the two coordinates it ends at are read back
|
||||||
|
// with two pops.
|
||||||
|
m.ps = append(m.ps[:0], m.y, m.x)
|
||||||
|
case 1: // a flex begins
|
||||||
|
m.inFlex = true
|
||||||
|
m.flexPts = m.flexPts[:0]
|
||||||
|
case 2: // a flex point has been collected by the move that followed
|
||||||
|
case 3:
|
||||||
|
// Hint replacement. The program pushes the number of the subroutine
|
||||||
|
// holding the hints it wants, and expects to read it straight back
|
||||||
|
// with a pop and then call it. Handing back anything else — the
|
||||||
|
// othersubr's own number, say — calls the wrong subroutine, and the
|
||||||
|
// glyph comes out empty with nothing to say why.
|
||||||
|
if len(args) > 0 {
|
||||||
|
m.ps = append(m.ps[:0], args[0])
|
||||||
|
} else {
|
||||||
|
m.ps = append(m.ps[:0], 3)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Hand the arguments back in the order the pops will take them.
|
||||||
|
m.ps = m.ps[:0]
|
||||||
|
for i := len(args) - 1; i >= 0; i-- {
|
||||||
|
m.ps = append(m.ps, args[i])
|
||||||
|
}
|
||||||
|
if len(m.ps) > maxT1PostScriptSize {
|
||||||
|
m.ps = m.ps[:maxT1PostScriptSize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endFlex turns the seven collected points into the two curves they stand for.
|
||||||
|
func (m *t1machine) endFlex() {
|
||||||
|
m.inFlex = false
|
||||||
|
if len(m.flexPts) < 7 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := m.flexPts[len(m.flexPts)-7:]
|
||||||
|
// The first of the seven is the reference point the shape is measured
|
||||||
|
// against and is not drawn.
|
||||||
|
m.curveTo(p[1].x, p[1].y, p[2].x, p[2].y, p[3].x, p[3].y)
|
||||||
|
m.curveTo(p[4].x, p[4].y, p[5].x, p[5].y, p[6].x, p[6].y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seac composes an accented glyph out of two others, named by their Standard
|
||||||
|
// Encoding codes.
|
||||||
|
func (m *t1machine) seac() error {
|
||||||
|
if m.seacDepth >= maxSeacDepth {
|
||||||
|
return fmt.Errorf("opentype: type1: accent composition nesting too deep")
|
||||||
|
}
|
||||||
|
s := m.stack
|
||||||
|
if len(s) < 5 {
|
||||||
|
m.clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
asb, adx, ady, bchar, achar := s[0], s[1], s[2], int(s[3]), int(s[4])
|
||||||
|
m.clear()
|
||||||
|
base, err := m.component(bchar)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accent, err := m.component(achar)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.finishContour()
|
||||||
|
m.contours = append(m.contours, base...)
|
||||||
|
// The accent is placed relative to the base's own left side bearing.
|
||||||
|
dx, dy := m.sbx-asb+adx, ady
|
||||||
|
for _, c := range accent {
|
||||||
|
nc := make(contour, len(c))
|
||||||
|
for i, p := range c {
|
||||||
|
nc[i] = outlinePoint{x: p.x + dx, y: p.y + dy, on: p.on}
|
||||||
|
}
|
||||||
|
m.contours = append(m.contours, nc)
|
||||||
|
}
|
||||||
|
m.done = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// component draws one half of an accented glyph, named by a Standard Encoding
|
||||||
|
// code.
|
||||||
|
func (m *t1machine) component(code int) ([]contour, error) {
|
||||||
|
sid, ok := standardEncodingSID(code)
|
||||||
|
if !ok || int(sid) >= nStdStrings {
|
||||||
|
return nil, fmt.Errorf("opentype: type1: code %d is not in the standard encoding", code)
|
||||||
|
}
|
||||||
|
return m.f.outlineByName(cffStandardStrings[sid], m.seacDepth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear empties the operand stack, which every operator that consumes one does.
|
||||||
|
func (m *t1machine) clear() { m.stack = m.stack[:0] }
|
||||||
|
|
||||||
|
// moveTo begins a contour, or collects a flex point while one is being built.
|
||||||
|
func (m *t1machine) moveTo(dx, dy float64) {
|
||||||
|
m.x += dx
|
||||||
|
m.y += dy
|
||||||
|
if m.inFlex {
|
||||||
|
m.flexPts = append(m.flexPts, outlinePoint{x: m.x, y: m.y, on: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.finishContour()
|
||||||
|
m.cur = contour{{x: m.x, y: m.y, on: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineTo adds a straight segment.
|
||||||
|
func (m *t1machine) lineTo(dx, dy float64) {
|
||||||
|
m.x += dx
|
||||||
|
m.y += dy
|
||||||
|
m.cur = append(m.cur, outlinePoint{x: m.x, y: m.y, on: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rrcurveTo adds a curve given by three relative steps.
|
||||||
|
func (m *t1machine) rrcurveTo(dx1, dy1, dx2, dy2, dx3, dy3 float64) {
|
||||||
|
x1, y1 := m.x+dx1, m.y+dy1
|
||||||
|
x2, y2 := x1+dx2, y1+dy2
|
||||||
|
m.curveTo(x1, y1, x2, y2, x2+dx3, y2+dy3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// curveTo flattens a cubic curve into the on-curve points a contour is made of.
|
||||||
|
func (m *t1machine) curveTo(x1, y1, x2, y2, x3, y3 float64) {
|
||||||
|
x0, y0 := m.x, m.y
|
||||||
|
for k := 1; k <= cffCurveSteps; k++ {
|
||||||
|
t := float64(k) / float64(cffCurveSteps)
|
||||||
|
u := 1 - t
|
||||||
|
bx := u*u*u*x0 + 3*u*u*t*x1 + 3*u*t*t*x2 + t*t*t*x3
|
||||||
|
by := u*u*u*y0 + 3*u*u*t*y1 + 3*u*t*t*y2 + t*t*t*y3
|
||||||
|
m.cur = append(m.cur, outlinePoint{x: bx, y: by, on: true})
|
||||||
|
}
|
||||||
|
m.x, m.y = x3, y3
|
||||||
|
}
|
||||||
|
|
||||||
|
// closePath ends the contour in progress. A Type 1 program says so out loud,
|
||||||
|
// where later formats leave it implied.
|
||||||
|
func (m *t1machine) closePath() {
|
||||||
|
m.finishContour()
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishContour puts the contour under construction with the others.
|
||||||
|
func (m *t1machine) finishContour() {
|
||||||
|
if len(m.cur) > 0 {
|
||||||
|
m.contours = append(m.contours, m.cur)
|
||||||
|
m.cur = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (c) 2026 the go-opentype/opentype authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-3-Clause license that can be
|
||||||
|
// found in the LICENSE file at the root of this repository.
|
||||||
|
|
||||||
|
package opentype
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// This file adds vertical writing-mode metrics: the data needed to lay glyphs
|
||||||
|
// out top-to-bottom for CJK tategaki and other vertical text. Three optional
|
||||||
|
// tables carry it:
|
||||||
|
//
|
||||||
|
// - vhea: the vertical header (ascender/descender/lineGap for vertical layout
|
||||||
|
// plus numOfLongVerMetrics, the horizontal analogue of hhea);
|
||||||
|
// - vmtx: per-glyph advanceHeight and topSideBearing (analogous to hmtx);
|
||||||
|
// - VORG: the Vertical Origin table, a default and per-glyph y coordinate of
|
||||||
|
// each glyph's vertical origin.
|
||||||
|
//
|
||||||
|
// Absence of these tables is not an error; it simply means vertical metrics are
|
||||||
|
// unavailable (HasVerticalMetrics reports false) and the caller falls back to
|
||||||
|
// the em square.
|
||||||
|
//
|
||||||
|
// Selecting vertical GLYPH FORMS (the OpenType 'vert'/'vrt2' features that swap
|
||||||
|
// horizontal glyphs for their upright vertical variants) is a shaping concern
|
||||||
|
// handled through GSUB (see gsub.go and Face.Shape). This file supplies the
|
||||||
|
// vertical METRICS and origin those laid-out glyphs are positioned with; it does
|
||||||
|
// not itself perform substitution.
|
||||||
|
|
||||||
|
// parseVertical decodes the optional vhea, vmtx and VORG tables. Each is
|
||||||
|
// independent, but vmtx is meaningful only alongside vhea (which supplies
|
||||||
|
// numOfLongVerMetrics), so a vmtx without a vhea is ignored. A malformed table
|
||||||
|
// that is present is reported as an error so a corrupt font fails cleanly.
|
||||||
|
func (f *Font) parseVertical(tables map[string][]byte) error {
|
||||||
|
if b, ok := tables["vhea"]; ok {
|
||||||
|
if err := f.parseVhea(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["vmtx"]; ok && f.hasVhea {
|
||||||
|
if err := f.parseVmtx(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, ok := tables["VORG"]; ok {
|
||||||
|
if err := f.parseVORG(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVhea reads the vertical metrics and numOfLongVerMetrics from vhea. Its
|
||||||
|
// layout mirrors hhea: vertTypoAscender/Descender/LineGap are int16 at offsets
|
||||||
|
// 4/6/8 and numOfLongVerMetrics is the uint16 at offset 34.
|
||||||
|
func (f *Font) parseVhea(b []byte) error {
|
||||||
|
if len(b) < 36 {
|
||||||
|
return fmt.Errorf("opentype: vhea table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.vertAscender = int(sbe16(b[4:]))
|
||||||
|
f.vertDescender = int(sbe16(b[6:]))
|
||||||
|
f.vertLineGap = int(sbe16(b[8:]))
|
||||||
|
f.numOfLongVerMetrics = int(be16(b[34:]))
|
||||||
|
if f.numOfLongVerMetrics == 0 || f.numOfLongVerMetrics > f.numGlyphs {
|
||||||
|
return fmt.Errorf("opentype: vhea table: bad numOfLongVerMetrics %d", f.numOfLongVerMetrics)
|
||||||
|
}
|
||||||
|
f.hasVhea = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVmtx expands the vmtx table into per-glyph advanceHeight and top side
|
||||||
|
// bearing slices, mirroring parseHmtx. The first numOfLongVerMetrics entries are
|
||||||
|
// (advanceHeight, topSideBearing) pairs; the trailing glyphs share the last
|
||||||
|
// entry's advance and carry their own top side bearing.
|
||||||
|
func (f *Font) parseVmtx(b []byte) error {
|
||||||
|
n := f.numOfLongVerMetrics
|
||||||
|
need := 4*n + 2*(f.numGlyphs-n)
|
||||||
|
if len(b) < need {
|
||||||
|
return fmt.Errorf("opentype: vmtx table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.vertAdvances = make([]int, f.numGlyphs)
|
||||||
|
f.tsbs = make([]int, f.numGlyphs)
|
||||||
|
lastAdvance := 0
|
||||||
|
for i := 0; i < f.numGlyphs; i++ {
|
||||||
|
if i < n {
|
||||||
|
lastAdvance = int(be16(b[i*4:]))
|
||||||
|
f.vertAdvances[i] = lastAdvance
|
||||||
|
f.tsbs[i] = int(sbe16(b[i*4+2:]))
|
||||||
|
} else {
|
||||||
|
f.vertAdvances[i] = lastAdvance
|
||||||
|
f.tsbs[i] = int(sbe16(b[4*n+2*(i-n):]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.hasVmtx = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVORG decodes the Vertical Origin table: an 8-byte header carrying the
|
||||||
|
// defaultVertOriginY (int16 at offset 4) and numVertOriginYMetrics (uint16 at
|
||||||
|
// offset 6), followed by that many 4-byte {glyphIndex uint16, vertOriginY int16}
|
||||||
|
// records overriding the default for specific glyphs.
|
||||||
|
func (f *Font) parseVORG(b []byte) error {
|
||||||
|
if len(b) < 8 {
|
||||||
|
return fmt.Errorf("opentype: VORG table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.vorgDefault = int(sbe16(b[4:]))
|
||||||
|
count := int(be16(b[6:]))
|
||||||
|
if len(b) < 8+4*count {
|
||||||
|
return fmt.Errorf("opentype: VORG table: %w", errTruncated)
|
||||||
|
}
|
||||||
|
f.vorg = make(map[GlyphIndex]int, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
rec := b[8+4*i:]
|
||||||
|
f.vorg[GlyphIndex(be16(rec))] = int(sbe16(rec[2:]))
|
||||||
|
}
|
||||||
|
f.hasVORG = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasVerticalMetrics reports whether the font carries the vertical header and
|
||||||
|
// per-glyph vertical advances (vhea and vmtx) required to lay text out
|
||||||
|
// top-to-bottom. When it returns false, VerticalAdvance falls back to the em
|
||||||
|
// square and VerticalMetrics returns zeroes.
|
||||||
|
func (f *Font) HasVerticalMetrics() bool {
|
||||||
|
return f.hasVhea && f.hasVmtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// verticalAdvance returns glyph gid's advance height in font units. When the
|
||||||
|
// font has no vmtx table it falls back to one em (unitsPerEm), the conventional
|
||||||
|
// full-width advance for vertical CJK layout.
|
||||||
|
func (f *Font) verticalAdvance(gid GlyphIndex) int {
|
||||||
|
if !f.hasVmtx {
|
||||||
|
return f.unitsPerEm
|
||||||
|
}
|
||||||
|
return f.vertAdvances[gid]
|
||||||
|
}
|
||||||
|
|
||||||
|
// verticalOriginY returns glyph gid's vertical origin y in font units and
|
||||||
|
// whether the font supplies one (a VORG table). A glyph without its own record
|
||||||
|
// takes the table's default vertical origin.
|
||||||
|
func (f *Font) verticalOriginY(gid GlyphIndex) (int, bool) {
|
||||||
|
if !f.hasVORG {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if y, ok := f.vorg[gid]; ok {
|
||||||
|
return y, true
|
||||||
|
}
|
||||||
|
return f.vorgDefault, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerticalAdvance returns the vertical advance (advance height) of r in pixels,
|
||||||
|
// or 0 if the rune is not mapped by the font's cmap. When the font has no vmtx
|
||||||
|
// table the advance is one em, scaled to the face's size.
|
||||||
|
//
|
||||||
|
// This is the top-to-bottom analogue of Advance. Choosing the upright vertical
|
||||||
|
// glyph form for r (the 'vert'/'vrt2' features) is a separate shaping step done
|
||||||
|
// through GSUB; VerticalAdvance measures whichever glyph the cmap maps.
|
||||||
|
func (fc *Face) VerticalAdvance(r rune) int {
|
||||||
|
gid, ok := fc.font.GlyphIndex(r)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
adv := float64(fc.font.verticalAdvance(gid))
|
||||||
|
if fc.varCoords != nil {
|
||||||
|
adv += fc.font.verticalAdvanceDelta(int(gid), fc.varNorm)
|
||||||
|
}
|
||||||
|
return roundInt(adv * fc.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerticalMetrics returns the face's vertical-layout line metrics in pixels: the
|
||||||
|
// vhea vertTypoAscender, vertTypoDescender and vertTypoLineGap scaled to the
|
||||||
|
// face's size. All three are zero when the font has no vhea table.
|
||||||
|
func (fc *Face) VerticalMetrics() (ascent, descent, lineGap int) {
|
||||||
|
s := fc.scale
|
||||||
|
return roundInt(float64(fc.font.vertAscender) * s),
|
||||||
|
roundInt(float64(fc.font.vertDescender) * s),
|
||||||
|
roundInt(float64(fc.font.vertLineGap) * s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerticalOrigin returns the y coordinate of r's vertical origin in pixels and
|
||||||
|
// whether the font supplies one (a VORG table). The origin locates the glyph
|
||||||
|
// relative to the pen when advancing top-to-bottom; ok is false when r is
|
||||||
|
// unmapped or the font has no VORG table.
|
||||||
|
func (fc *Face) VerticalOrigin(r rune) (int, bool) {
|
||||||
|
gid, mapped := fc.font.GlyphIndex(r)
|
||||||
|
if !mapped {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
y, ok := fc.font.verticalOriginY(gid)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return roundInt(float64(y) * fc.scale), true
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
coverage.out
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026 the go-pdfkit/render authors
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
# extract
|
||||||
|
|
||||||
|
[](https://github.com/go-pdfkit/extract/actions/workflows/ci.yml)
|
||||||
|
[](https://pkg.go.dev/github.com/go-pdfkit/extract)
|
||||||
|
[](LICENSE)
|
||||||
|
[](#how-it-is-checked)
|
||||||
|
|
||||||
|
Reads a PDF page back: the text on it, with where each piece of it sits, and
|
||||||
|
the images it places.
|
||||||
|
|
||||||
|
```go
|
||||||
|
text, err := extract.Text(doc, 1) // the page as words, lines put back together
|
||||||
|
runs, err := extract.Runs(doc, 1) // every piece, with its place and its size
|
||||||
|
imgs, err := extract.Images(doc, 1) // every picture, and where it lands
|
||||||
|
```
|
||||||
|
|
||||||
|
Pure Go, no C. It builds for `GOOS=js/wasm` and every architecture the fleet
|
||||||
|
targets.
|
||||||
|
|
||||||
|
## What a page actually holds
|
||||||
|
|
||||||
|
Not text. A page holds instructions for drawing glyphs, and the text is what
|
||||||
|
those glyphs were meant to say — which the document says only if it was asked
|
||||||
|
to. Where it does not, this says so rather than guessing: a run whose codes
|
||||||
|
could not be worked out comes back marked `Unreadable`, so a caller can tell
|
||||||
|
*this page says nothing* from *this page could not be read*.
|
||||||
|
|
||||||
|
Three things are tried, in order:
|
||||||
|
|
||||||
|
1. the font's `/ToUnicode` map, which is the document's own word on what its
|
||||||
|
text says;
|
||||||
|
2. the name its encoding gives the code — but only a name the document chose
|
||||||
|
itself, since a **symbolic** font read through an assumed encoding gives
|
||||||
|
the wrong letter with nothing to say so;
|
||||||
|
3. what the **embedded font program** calls the code, read with
|
||||||
|
[`go-opentype`](https://github.com/go-opentype/opentype) — which is the
|
||||||
|
honest answer for a symbolic font, and the reason a page of mathematics
|
||||||
|
comes back as mathematics.
|
||||||
|
|
||||||
|
Text drawn in the mode that puts no ink on the page — how a scanner writes
|
||||||
|
what it read underneath the picture it read it from — is returned and marked
|
||||||
|
`Invisible`.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
A picture comes back as the file holds it, with the box it covers on the page.
|
||||||
|
A JPEG comes out as a JPEG: re-encoding it would lose something for nothing.
|
||||||
|
Turning the other kinds into pixels means reading their colour space, which is
|
||||||
|
[`render`](https://github.com/go-pdfkit/render)'s work rather than this
|
||||||
|
package's.
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
100% of statements, including every branch that handles a page saying
|
||||||
|
something it should not.
|
||||||
|
|
||||||
|
Then on the corpus — 118 833 real PDFs, 121 946 pages:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages read 121946
|
||||||
|
with text 94865 (77.8%)
|
||||||
|
saying nothing 27081, of which 4812 did draw glyphs
|
||||||
|
characters 33976414 (17272226 of them letters)
|
||||||
|
runs 10797045
|
||||||
|
unreadable 203607 (1.89%)
|
||||||
|
invisible 29917
|
||||||
|
images 300685
|
||||||
|
panics 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The 22% of pages that say nothing are figures — this corpus is arXiv's, and
|
||||||
|
most of it is plots. Of the pages that *did* draw glyphs and still said
|
||||||
|
nothing, nearly all are Type 3 fonts with no `/ToUnicode`: dvips bitmap fonts
|
||||||
|
whose glyphs are called `a1` and `s32`, which no reader can turn back into
|
||||||
|
letters.
|
||||||
|
|
||||||
|
## The rest of the family
|
||||||
|
|
||||||
|
[**reader**](https://github.com/go-pdfkit/reader) reads and writes the format ·
|
||||||
|
[**ops**](https://github.com/go-pdfkit/ops) is the verbs and the `pdfops` command ·
|
||||||
|
[**render**](https://github.com/go-pdfkit/render) turns a page into pixels ·
|
||||||
|
[**pdffont**](https://github.com/go-pdfkit/pdffont) is what a document says about a font ·
|
||||||
|
[**extract**](https://github.com/go-pdfkit/extract) reads a page back ·
|
||||||
|
[**coedit**](https://github.com/go-pdfkit/coedit) is a PDF several people edit at once ·
|
||||||
|
[**app**](https://github.com/go-pdfkit/app) is the browser workbench.
|
||||||
|
|
||||||
|
Documentation for all of it: <https://go-pdfkit.github.io/docs/>
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
BSD-3-Clause.
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
// Package extract reads a PDF page back: the text on it, with where each piece
|
||||||
|
// of it sits, and the images it places.
|
||||||
|
//
|
||||||
|
// A page does not hold text. It holds instructions for drawing glyphs, and the
|
||||||
|
// text is what those glyphs were meant to say — which the document says only
|
||||||
|
// if it was asked to. Where it does not, this says so rather than guessing:
|
||||||
|
// a run whose characters could not be worked out comes back marked, so that a
|
||||||
|
// caller can tell "this page says nothing" from "this page could not be read".
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdfkit/pdffont"
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Run is a piece of text drawn in one go: one font, one size, one place.
|
||||||
|
type Run struct {
|
||||||
|
// Text is what the run says.
|
||||||
|
Text string
|
||||||
|
// X and Y are where it starts, in the page's own coordinates — up from
|
||||||
|
// the bottom left corner, in points.
|
||||||
|
X, Y float64
|
||||||
|
// Width is how far the pen moved drawing it, and Size how tall the text
|
||||||
|
// is, both in points.
|
||||||
|
Width, Size float64
|
||||||
|
// Font is the name the page's resources give the font.
|
||||||
|
Font string
|
||||||
|
// Space is how wide a space would be here, which is what says whether a
|
||||||
|
// gap between two runs is a word break or only kerning.
|
||||||
|
Space float64
|
||||||
|
// Invisible reports text drawn in the mode that puts no ink on the page.
|
||||||
|
// It is how a scanner puts what it read underneath the picture it read it
|
||||||
|
// from, so it is text worth having — and worth knowing about.
|
||||||
|
Invisible bool
|
||||||
|
// Unreadable reports a run some of whose codes said nothing about which
|
||||||
|
// characters they were: the font carried no map and named its glyphs
|
||||||
|
// after numbers. What Text holds is then only the part that could be read.
|
||||||
|
Unreadable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text is everything the page says, in reading order, with the lines put back
|
||||||
|
// together.
|
||||||
|
func Text(d *reader.Document, page int) (string, error) {
|
||||||
|
runs, err := Runs(d, page)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Assemble(runs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs is every piece of text on the page, in the order it was drawn.
|
||||||
|
func Runs(d *reader.Document, page int) ([]Run, error) {
|
||||||
|
e, err := walk(d, page, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk reads a page's content stream once, keeping what it finds.
|
||||||
|
//
|
||||||
|
// A page whose content decoded only part of the way is read as far as it went:
|
||||||
|
// 263 streams in 212 of the 1 633 real forms in the corpus cannot be decoded
|
||||||
|
// cleanly, and half a page of text is worth more to somebody searching than
|
||||||
|
// none of it. A page that decoded no bytes at all is a different answer and
|
||||||
|
// stays an error — a conversion that silently produces an empty document from
|
||||||
|
// an unreadable page is worse than one that says it could not read it, and
|
||||||
|
// go-pdfkit/latex relies on being told.
|
||||||
|
//
|
||||||
|
// wantPictures says whether the caller is going to look at the pictures. A
|
||||||
|
// page's images have nothing to do with its text, and undoing their filters is
|
||||||
|
// the most expensive thing on the page: one arXiv figure holds 378 MB of image
|
||||||
|
// once decompressed, so asking that page what it *said* used to cost 1 094 MB
|
||||||
|
// and 1.3 seconds spent inflating pictures the answer throws away. Where they
|
||||||
|
// land is still noted either way, since that costs a matrix multiply; only the
|
||||||
|
// bytes are left alone.
|
||||||
|
func walk(d *reader.Document, page int, wantPictures bool) (*extractor, error) {
|
||||||
|
dec, err := d.PageContentDecoded(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(dec.Data) == 0 && dec.Cause != nil {
|
||||||
|
return nil, dec.Cause
|
||||||
|
}
|
||||||
|
content := dec.Data
|
||||||
|
p, _ := d.Page(page)
|
||||||
|
resources, _ := d.GetDict(p, "Resources")
|
||||||
|
e := &extractor{doc: d, fonts: map[int]*pdffont.Font{}, wantPictures: wantPictures}
|
||||||
|
e.run(content, resources, initialState(d, p), 0)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble puts runs back together as lines of text: everything on one
|
||||||
|
// baseline in the order it sits across the page, with a space wherever the
|
||||||
|
// gap between two pieces is wide enough to be one.
|
||||||
|
func Assemble(runs []Run) string {
|
||||||
|
if len(runs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := groupIntoLines(runs)
|
||||||
|
var out strings.Builder
|
||||||
|
for i, line := range lines {
|
||||||
|
if i > 0 {
|
||||||
|
out.WriteByte('\n')
|
||||||
|
}
|
||||||
|
out.WriteString(joinLine(line))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupIntoLines gathers runs that sit on the same baseline, in the order they
|
||||||
|
// appear down the page.
|
||||||
|
func groupIntoLines(runs []Run) [][]Run {
|
||||||
|
sorted := append([]Run{}, runs...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
if !sameLine(sorted[i], sorted[j]) {
|
||||||
|
return sorted[i].Y > sorted[j].Y
|
||||||
|
}
|
||||||
|
return sorted[i].X < sorted[j].X
|
||||||
|
})
|
||||||
|
var lines [][]Run
|
||||||
|
for _, r := range sorted {
|
||||||
|
if n := len(lines); n > 0 && sameLine(lines[n-1][0], r) {
|
||||||
|
lines[n-1] = append(lines[n-1], r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, []Run{r})
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameLine reports whether two runs sit on the same baseline. A line is
|
||||||
|
// allowed to wobble by a fraction of its own height, which is what a
|
||||||
|
// superscript or a slightly different font does.
|
||||||
|
func sameLine(a, b Run) bool {
|
||||||
|
tol := a.Size * 0.3
|
||||||
|
if tol <= 0 {
|
||||||
|
tol = 1
|
||||||
|
}
|
||||||
|
d := a.Y - b.Y
|
||||||
|
return d < tol && d > -tol
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinLine writes one line out, putting a space wherever the gap between two
|
||||||
|
// pieces is wide enough to be one.
|
||||||
|
func joinLine(line []Run) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for i, r := range line {
|
||||||
|
if i > 0 {
|
||||||
|
prev := line[i-1]
|
||||||
|
gap := r.X - (prev.X + prev.Width)
|
||||||
|
if wantsSpace(prev, r, gap) {
|
||||||
|
out.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WriteString(r.Text)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(out.String(), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsSpace decides whether a gap between two pieces of a line is a word
|
||||||
|
// break. A space of its own is never doubled, and a gap of about a third of a
|
||||||
|
// space is where kerning ends and words begin.
|
||||||
|
func wantsSpace(prev, next Run, gap float64) bool {
|
||||||
|
if strings.HasSuffix(prev.Text, " ") || strings.HasPrefix(next.Text, " ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
space := prev.Space
|
||||||
|
if space <= 0 {
|
||||||
|
space = prev.Size * 0.25
|
||||||
|
}
|
||||||
|
return gap > space*0.3
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An Image is a picture the page places, and where it puts it.
|
||||||
|
//
|
||||||
|
// What comes back is the picture as the file holds it — the bytes and the
|
||||||
|
// dictionary describing them — rather than pixels. A JPEG comes out as a JPEG,
|
||||||
|
// which is what anyone pulling pictures out of a document wants: re-encoding
|
||||||
|
// it would lose something for nothing. Turning the other kinds into pixels
|
||||||
|
// means reading their colour space, which is the renderer's work rather than
|
||||||
|
// this package's.
|
||||||
|
type Image struct {
|
||||||
|
// Name is what the page's resources call it, or empty for one written
|
||||||
|
// into the content stream.
|
||||||
|
Name string
|
||||||
|
// Width and Height are the picture's own, in samples.
|
||||||
|
Width, Height int
|
||||||
|
// X and Y are where its bottom left corner lands on the page, and
|
||||||
|
// DrawnWidth and DrawnHeight how large it is drawn, in points. A picture
|
||||||
|
// turned or skewed by the page still reports the box it covers.
|
||||||
|
X, Y float64
|
||||||
|
DrawnWidth, DrawnHeight float64
|
||||||
|
// Filter is the last filter still on the data: DCTDecode for a JPEG that
|
||||||
|
// comes out as one, JPXDecode for a JPEG 2000, and empty for data this
|
||||||
|
// has unfiltered into plain samples.
|
||||||
|
Filter reader.Name
|
||||||
|
// Data is the picture's bytes, with every filter this understands undone.
|
||||||
|
Data []byte
|
||||||
|
// Dict is the image's own dictionary, which says how to read the data:
|
||||||
|
// its colour space, how many bits a sample takes, and whether it is a
|
||||||
|
// mask.
|
||||||
|
Dict reader.Dict
|
||||||
|
// Inline reports a picture written into the content stream rather than
|
||||||
|
// held as a resource of its own.
|
||||||
|
Inline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images is every picture the page places, in the order it places them. The
|
||||||
|
// same picture placed twice comes back twice, since where it lands is part of
|
||||||
|
// what is being asked for.
|
||||||
|
func Images(d *reader.Document, page int) ([]Image, error) {
|
||||||
|
e, err := walk(d, page, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.images, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doXObject handles the Do operator: a form is walked into, and a picture is
|
||||||
|
// noted where it lands.
|
||||||
|
func (e *extractor) doXObject(g *state, operands []reader.Object, resources reader.Dict, depth int) {
|
||||||
|
if len(operands) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, ok := reader.ToName(operands[0])
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xobjects, ok := e.doc.GetDict(resources, "XObject")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream, ok := reader.ToStream(resolve(e.doc, xobjects.Get(name)))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch sub, _ := reader.ToName(resolve(e.doc, stream.Dict.Get("Subtype"))); sub {
|
||||||
|
case "Form":
|
||||||
|
e.doForm(g, stream, resources, depth)
|
||||||
|
case "Image":
|
||||||
|
e.noteImage(g, string(name), stream.Dict, stream.Raw, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doForm walks into a form, which is a piece of a page held apart and drawn
|
||||||
|
// where the page says.
|
||||||
|
func (e *extractor) doForm(g *state, stream *reader.Stream, resources reader.Dict, depth int) {
|
||||||
|
data, img, err := e.doc.DecodeStream(stream)
|
||||||
|
if err != nil || img != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner := *g
|
||||||
|
if m := floatArray(e.doc, stream.Dict.Get("Matrix")); len(m) >= 6 {
|
||||||
|
inner.ctm = matrix{m[0], m[1], m[2], m[3], m[4], m[5]}.mul(g.ctm)
|
||||||
|
}
|
||||||
|
own, ok := e.doc.GetDict(stream.Dict, "Resources")
|
||||||
|
if !ok {
|
||||||
|
own = resources
|
||||||
|
}
|
||||||
|
e.run(data, own, inner, depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineImage notes a picture written into the content stream.
|
||||||
|
func (e *extractor) inlineImage(g *state, im *reader.InlineImage) {
|
||||||
|
if im == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.noteImage(g, "", im.Expanded(), im.Raw, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteImage works out where a picture lands and records it. A picture occupies
|
||||||
|
// the unit square of the transform in force, so its corners are where that
|
||||||
|
// square's are.
|
||||||
|
func (e *extractor) noteImage(g *state, name string, dict reader.Dict, raw []byte, inline bool) {
|
||||||
|
minX, minY, maxX, maxY := unitSquareBox(g.ctm)
|
||||||
|
img := Image{
|
||||||
|
Name: name,
|
||||||
|
Width: int(intOf(resolve(e.doc, dict.Get("Width")), 0)),
|
||||||
|
Height: int(intOf(resolve(e.doc, dict.Get("Height")), 0)),
|
||||||
|
X: minX,
|
||||||
|
Y: minY,
|
||||||
|
DrawnWidth: maxX - minX,
|
||||||
|
DrawnHeight: maxY - minY,
|
||||||
|
Dict: dict,
|
||||||
|
Inline: inline,
|
||||||
|
}
|
||||||
|
// A caller after the text is not after the pixels, and undoing a
|
||||||
|
// picture's filters to hand it nothing is the most expensive thing a page
|
||||||
|
// can be made to do.
|
||||||
|
if e.wantPictures {
|
||||||
|
data, filter, err := reader.Decode(dict, raw, e.doc.Resolver())
|
||||||
|
if err == nil {
|
||||||
|
img.Data, img.Filter = data, filter
|
||||||
|
} else {
|
||||||
|
img.Data = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.images = append(e.images, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitSquareBox is the box the unit square covers once the transform has had
|
||||||
|
// its way with it.
|
||||||
|
func unitSquareBox(m matrix) (minX, minY, maxX, maxY float64) {
|
||||||
|
first := true
|
||||||
|
for _, c := range [][2]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}} {
|
||||||
|
x, y := m.apply(c[0], c[1])
|
||||||
|
if first {
|
||||||
|
minX, minY, maxX, maxY = x, y, x, y
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minX, maxX = smaller(minX, x), larger(maxX, x)
|
||||||
|
minY, maxY = smaller(minY, y), larger(maxY, y)
|
||||||
|
}
|
||||||
|
return minX, minY, maxX, maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
func smaller(a, b float64) float64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func larger(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// intOf reads a whole number, or gives a default.
|
||||||
|
func intOf(o reader.Object, def int64) int64 {
|
||||||
|
if v, ok := reader.ToInt(o); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// floatArray reads an array of numbers, or nothing.
|
||||||
|
func floatArray(d *reader.Document, o reader.Object) []float64 {
|
||||||
|
arr, ok := reader.ToArray(resolve(d, o))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]float64, 0, len(arr))
|
||||||
|
for _, e := range arr {
|
||||||
|
v, ok := reader.ToFloat(resolve(d, e))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-opentype/opentype"
|
||||||
|
"github.com/go-pdfkit/pdffont"
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attachProgram gives a font a last resort for the codes the document says
|
||||||
|
// nothing about: what its own embedded program calls them.
|
||||||
|
//
|
||||||
|
// A font that says it is symbolic is addressed through its own character map,
|
||||||
|
// and an assumed encoding is a bad guess at it — a mathematical font puts a
|
||||||
|
// capital gamma where the standard encoding puts an inverted exclamation mark.
|
||||||
|
// The program knows, and this is how it is asked.
|
||||||
|
//
|
||||||
|
// There are two ways to ask, and a program answers at most one of them. A
|
||||||
|
// PostScript program names its glyphs and carries an encoding from code to
|
||||||
|
// name, so the name is the answer. A TrueType program usually does neither —
|
||||||
|
// a subset of one names nothing worth reading and has no code-to-name
|
||||||
|
// encoding at all — but it does carry character maps, and walking through one
|
||||||
|
// to the glyph and back out of another says which character the glyph is for.
|
||||||
|
func attachProgram(f *pdffont.Font) {
|
||||||
|
if f.Kind() == pdffont.Composite {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, data, ok := f.Program()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
program, err := readProgram(key, data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maps := chooseCharacterMaps(program)
|
||||||
|
// Only a simple font reaches here, and its codes are bytes.
|
||||||
|
f.SetFallback(func(code int) (string, bool) {
|
||||||
|
if r, ok := runeByName(program, code); ok {
|
||||||
|
return string(r), true
|
||||||
|
}
|
||||||
|
if r, ok := runeByCharacterMap(program, maps, code); ok {
|
||||||
|
return string(r), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeByName asks the program's own encoding what it calls a code, and reads
|
||||||
|
// the character out of that name. Only a PostScript program — a Type 1 one, or
|
||||||
|
// the CFF outlines of an OpenType font — carries such an encoding.
|
||||||
|
func runeByName(program *opentype.Font, code int) (rune, bool) {
|
||||||
|
gid, ok := program.GlyphIndexByCode(byte(code))
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// A glyph the program does not name comes back as no name at all,
|
||||||
|
// which names no character either.
|
||||||
|
name, _ := program.GlyphName(gid)
|
||||||
|
return pdffont.RuneOfGlyphName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// characterMaps says which of a program's cmap subtables are worth addressing,
|
||||||
|
// by index, or -1 for one the program does not carry.
|
||||||
|
type characterMaps struct {
|
||||||
|
// symbol is the Microsoft Symbol subtable, platform 3 encoding 0: a font's
|
||||||
|
// own codes, conventionally written at 0xF000 + code.
|
||||||
|
symbol int
|
||||||
|
// mac is the Macintosh Roman subtable, platform 1 encoding 0, indexed by
|
||||||
|
// single bytes.
|
||||||
|
mac int
|
||||||
|
// unicode is a Unicode subtable — Microsoft Unicode, platform 3 encoding 1
|
||||||
|
// or 10, or anything on platform 0 — indexed by codepoint. This is the one
|
||||||
|
// that is inverted, because it is the only one whose codes are characters.
|
||||||
|
unicode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// chooseCharacterMaps picks out the subtables a font is addressed through and
|
||||||
|
// the one that says which character a glyph is for.
|
||||||
|
//
|
||||||
|
// A font can carry several subtables of a kind; the first of each is taken,
|
||||||
|
// which is what a font that repeats one means by repeating it.
|
||||||
|
func chooseCharacterMaps(program *opentype.Font) characterMaps {
|
||||||
|
m := characterMaps{symbol: -1, mac: -1, unicode: -1}
|
||||||
|
for i := range program.NumCharacterMaps() {
|
||||||
|
platform, encoding, _, _ := program.CharacterMap(i)
|
||||||
|
switch {
|
||||||
|
case platform == 3 && encoding == 0:
|
||||||
|
if m.symbol < 0 {
|
||||||
|
m.symbol = i
|
||||||
|
}
|
||||||
|
case platform == 1 && encoding == 0:
|
||||||
|
if m.mac < 0 {
|
||||||
|
m.mac = i
|
||||||
|
}
|
||||||
|
case platform == 0, platform == 3 && (encoding == 1 || encoding == 10):
|
||||||
|
if m.unicode < 0 {
|
||||||
|
m.unicode = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeByCharacterMap walks a code through the font's own character map to a
|
||||||
|
// glyph, and back out of its Unicode character map to the character that glyph
|
||||||
|
// stands for.
|
||||||
|
//
|
||||||
|
// The way in is the one poppler uses for a font the document gave no encoding:
|
||||||
|
// the Microsoft Symbol subtable if there is one, else the Macintosh Roman one,
|
||||||
|
// addressed by the raw code and then, failing that, by 0xF000 + code, which is
|
||||||
|
// where such subtables are conventionally written.
|
||||||
|
//
|
||||||
|
// The way out is the Unicode subtable, inverted. Without one there is no way
|
||||||
|
// out: a glyph on its own says nothing about which character it is, and a
|
||||||
|
// guess would be exactly the wrong letter the caller's guard exists to refuse.
|
||||||
|
func runeByCharacterMap(program *opentype.Font, m characterMaps, code int) (rune, bool) {
|
||||||
|
if m.unicode < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
in := m.symbol
|
||||||
|
if in < 0 {
|
||||||
|
in = m.mac
|
||||||
|
}
|
||||||
|
if in < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
gid, ok := program.GlyphIndexInMap(in, rune(code))
|
||||||
|
if !ok {
|
||||||
|
gid, ok = program.GlyphIndexInMap(in, rune(0xF000|code))
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
r, ok := program.RuneOfGlyphInMap(m.unicode, gid)
|
||||||
|
if !ok || !readableRune(r) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readableRune reports whether a character recovered this way is worth
|
||||||
|
// reporting as text.
|
||||||
|
//
|
||||||
|
// A private-use codepoint is not: it means whatever the font decided it means
|
||||||
|
// and nothing outside the font can read it, so a page full of them searches no
|
||||||
|
// better than a page of nothing and looks, wrongly, like it was read. Control
|
||||||
|
// characters are refused for the same reason — a page does not say them.
|
||||||
|
func readableRune(r rune) bool {
|
||||||
|
switch {
|
||||||
|
case r < 0x20, r >= 0x7F && r <= 0x9F:
|
||||||
|
return false
|
||||||
|
case r >= 0xE000 && r <= 0xF8FF:
|
||||||
|
return false
|
||||||
|
case r >= 0xF0000:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProgram decodes an embedded font program. Which key it arrived under
|
||||||
|
// says what it is: FontFile2 is TrueType, FontFile a PostScript Type 1
|
||||||
|
// program, and FontFile3 either a bare CFF one or a whole OpenType font — the
|
||||||
|
// key alone does not settle that one, so both are tried.
|
||||||
|
func readProgram(key reader.Name, data []byte) (*opentype.Font, error) {
|
||||||
|
switch key {
|
||||||
|
case "FontFile":
|
||||||
|
return opentype.ParseType1(data)
|
||||||
|
case "FontFile3":
|
||||||
|
if f, err := opentype.Parse(data); err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
return opentype.ParseCFF(data)
|
||||||
|
}
|
||||||
|
return opentype.Parse(data)
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["github>go-pdfkit/.github"]
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdfkit/pdffont"
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxFormDepth is how deeply one form may draw another before a page is taken
|
||||||
|
// to be drawing itself.
|
||||||
|
const maxFormDepth = 12
|
||||||
|
|
||||||
|
// An extractor walks a content stream keeping just enough state to say where
|
||||||
|
// each piece of text was drawn.
|
||||||
|
type extractor struct {
|
||||||
|
doc *reader.Document
|
||||||
|
runs []Run
|
||||||
|
fonts map[int]*pdffont.Font
|
||||||
|
// tm is where the next glyph goes and tlm where the line began; both
|
||||||
|
// belong to the page rather than to the graphics state.
|
||||||
|
tm, tlm matrix
|
||||||
|
images []Image
|
||||||
|
// wantPictures says whether anybody is going to read Image.Data. When
|
||||||
|
// nobody is, the filters are left undone.
|
||||||
|
wantPictures bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// run walks a content stream.
|
||||||
|
func (e *extractor) run(content []byte, resources reader.Dict, g state, depth int) {
|
||||||
|
if depth > maxFormDepth {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The operations are taken one at a time rather than all at once. The
|
||||||
|
// walk only ever goes forwards, so nothing needs the list; keeping it
|
||||||
|
// costs a struct and a slice header for every operator on the page, and
|
||||||
|
// pages are not always small. One arXiv figure holds a single page of
|
||||||
|
// 84.8 MB of content stream and 4 579 973 operations: holding them cost
|
||||||
|
// 2 166 MB where reading them one by one costs 935 MB.
|
||||||
|
scan := reader.NewContentScanner(content)
|
||||||
|
var stack []state
|
||||||
|
for {
|
||||||
|
op, more := scan.Next()
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n := numbers(op.Operands)
|
||||||
|
switch op.Operator {
|
||||||
|
case "q":
|
||||||
|
stack = append(stack, g)
|
||||||
|
case "Q":
|
||||||
|
if len(stack) > 0 {
|
||||||
|
g = stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
case "cm":
|
||||||
|
if len(n) >= 6 {
|
||||||
|
g.ctm = matrix{n[0], n[1], n[2], n[3], n[4], n[5]}.mul(g.ctm)
|
||||||
|
}
|
||||||
|
case "BT":
|
||||||
|
e.tm, e.tlm = identity, identity
|
||||||
|
case "ET":
|
||||||
|
e.tm, e.tlm = identity, identity
|
||||||
|
case "Tf":
|
||||||
|
e.setFont(&g, op.Operands, n, resources)
|
||||||
|
case "Tc":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.charSpace = n[0]
|
||||||
|
}
|
||||||
|
case "Tw":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.wordSpace = n[0]
|
||||||
|
}
|
||||||
|
case "Tz":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.scale = n[0] / 100
|
||||||
|
}
|
||||||
|
case "TL":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.leading = n[0]
|
||||||
|
}
|
||||||
|
case "Ts":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.rise = n[0]
|
||||||
|
}
|
||||||
|
case "Tr":
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.mode = int(n[0])
|
||||||
|
}
|
||||||
|
case "Td":
|
||||||
|
if len(n) >= 2 {
|
||||||
|
e.tlm = matrix{1, 0, 0, 1, n[0], n[1]}.mul(e.tlm)
|
||||||
|
e.tm = e.tlm
|
||||||
|
}
|
||||||
|
case "TD":
|
||||||
|
if len(n) >= 2 {
|
||||||
|
g.text.leading = -n[1]
|
||||||
|
e.tlm = matrix{1, 0, 0, 1, n[0], n[1]}.mul(e.tlm)
|
||||||
|
e.tm = e.tlm
|
||||||
|
}
|
||||||
|
case "Tm":
|
||||||
|
if len(n) >= 6 {
|
||||||
|
e.tlm = matrix{n[0], n[1], n[2], n[3], n[4], n[5]}
|
||||||
|
e.tm = e.tlm
|
||||||
|
}
|
||||||
|
case "T*":
|
||||||
|
e.nextLine(&g)
|
||||||
|
case "Tj":
|
||||||
|
e.showOperands(&g, op.Operands)
|
||||||
|
case "'":
|
||||||
|
e.nextLine(&g)
|
||||||
|
e.showOperands(&g, op.Operands)
|
||||||
|
case "\"":
|
||||||
|
if len(n) >= 2 {
|
||||||
|
g.text.wordSpace, g.text.charSpace = n[0], n[1]
|
||||||
|
}
|
||||||
|
e.nextLine(&g)
|
||||||
|
e.showOperands(&g, op.Operands)
|
||||||
|
case "TJ":
|
||||||
|
e.showArray(&g, op.Operands)
|
||||||
|
case "Do":
|
||||||
|
e.doXObject(&g, op.Operands, resources, depth)
|
||||||
|
case "BI":
|
||||||
|
e.inlineImage(&g, op.Image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFont reads the font a Tf operator names.
|
||||||
|
func (e *extractor) setFont(g *state, operands []reader.Object, n []float64, resources reader.Dict) {
|
||||||
|
if len(operands) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, ok := reader.ToName(operands[0])
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(n) >= 1 {
|
||||||
|
g.text.size = n[len(n)-1]
|
||||||
|
}
|
||||||
|
g.text.fontName = string(name)
|
||||||
|
g.text.font = e.font(name, resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// font reads a font out of the page's resources, remembering the ones already
|
||||||
|
// read so that a page setting the same font a thousand times reads it once.
|
||||||
|
func (e *extractor) font(name reader.Name, resources reader.Dict) *pdffont.Font {
|
||||||
|
fonts, ok := e.doc.GetDict(resources, "Font")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ref, ok := fonts.Get(name).(reader.Ref); ok {
|
||||||
|
if f, seen := e.fonts[ref.Num]; seen {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
dict, ok := e.doc.GetDict(fonts, name)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f := pdffont.Read(e.doc, dict)
|
||||||
|
attachProgram(f)
|
||||||
|
e.fonts[ref.Num] = f
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
dict, ok := e.doc.GetDict(fonts, name)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f := pdffont.Read(e.doc, dict)
|
||||||
|
attachProgram(f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextLine moves down by the leading, which is what T* and the quote
|
||||||
|
// operators do.
|
||||||
|
func (e *extractor) nextLine(g *state) {
|
||||||
|
e.tlm = matrix{1, 0, 0, 1, 0, -g.text.leading}.mul(e.tlm)
|
||||||
|
e.tm = e.tlm
|
||||||
|
}
|
||||||
|
|
||||||
|
// showOperands shows the string a Tj-like operator carries.
|
||||||
|
func (e *extractor) showOperands(g *state, operands []reader.Object) {
|
||||||
|
for _, o := range operands {
|
||||||
|
if s, ok := reader.ToString(o); ok {
|
||||||
|
e.show(g, s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showArray shows the pieces a TJ operator carries, moving the pen by the
|
||||||
|
// numbers between them.
|
||||||
|
func (e *extractor) showArray(g *state, operands []reader.Object) {
|
||||||
|
for _, o := range operands {
|
||||||
|
arr, ok := reader.ToArray(o)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range arr {
|
||||||
|
if s, ok := reader.ToString(item); ok {
|
||||||
|
e.show(g, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := reader.ToFloat(item); ok {
|
||||||
|
e.advance(g, -v/1000*g.text.size*g.text.scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// show reads one string as text and records where it was drawn.
|
||||||
|
func (e *extractor) show(g *state, s []byte) {
|
||||||
|
f := g.text.font
|
||||||
|
if f == nil || len(s) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := e.tm.mul(g.ctm)
|
||||||
|
x, y := start.apply(0, g.text.rise)
|
||||||
|
var text strings.Builder
|
||||||
|
unreadable := false
|
||||||
|
width := 0.0
|
||||||
|
for _, code := range f.Codes(s) {
|
||||||
|
if t, ok := f.Text(code); ok {
|
||||||
|
text.WriteString(pdffont.TrimText(t))
|
||||||
|
} else {
|
||||||
|
unreadable = true
|
||||||
|
}
|
||||||
|
adv := (f.Width(code)*g.text.size + g.text.charSpace) * g.text.scale
|
||||||
|
if f.Kind() != pdffont.Composite && code == ' ' {
|
||||||
|
adv += g.text.wordSpace * g.text.scale
|
||||||
|
}
|
||||||
|
width += adv
|
||||||
|
}
|
||||||
|
e.advance(g, width)
|
||||||
|
if text.Len() == 0 && !unreadable {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// How far a length in text space reaches on the page: the text matrix and
|
||||||
|
// the page's transform together, which is the same matrix the run's own
|
||||||
|
// position came through.
|
||||||
|
//
|
||||||
|
// Only the page's transform used to be counted here, and the text matrix
|
||||||
|
// was left out. A document is free to put the whole of its scale there —
|
||||||
|
// "/F1 1 Tf" and then a text matrix of ten is how TeX writes every PDF it
|
||||||
|
// has ever produced — and such a document reported its text as one point
|
||||||
|
// tall with letters half a point wide, while saying quite correctly where
|
||||||
|
// on the page each run began.
|
||||||
|
//
|
||||||
|
// Those are the two numbers a word break is decided by. A gap measured in
|
||||||
|
// points was being weighed against a space width ten times too small, so
|
||||||
|
// every ordinary kern between two letters looked like a space: "Original
|
||||||
|
// Domain" came back as "Or ig inal D omain". Across four thousand arXiv
|
||||||
|
// figures, 57% of the words a reader got back were one or two letters
|
||||||
|
// long. Nothing failed, and nothing was slow; the text was simply no
|
||||||
|
// longer text.
|
||||||
|
scale := start.scale()
|
||||||
|
e.runs = append(e.runs, Run{
|
||||||
|
Text: text.String(),
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
Width: width * scale,
|
||||||
|
Size: g.text.size * scale,
|
||||||
|
Font: g.text.fontName,
|
||||||
|
Space: f.Width(' ') * g.text.size * g.text.scale * scale,
|
||||||
|
Invisible: g.text.mode == 3,
|
||||||
|
Unreadable: unreadable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance moves the pen along the line by a distance in text space.
|
||||||
|
func (e *extractor) advance(g *state, by float64) {
|
||||||
|
e.tm = matrix{1, 0, 0, 1, by, 0}.mul(e.tm)
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-pdfkit/pdffont"
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A matrix is the six numbers a PDF transform is written as. Extraction needs
|
||||||
|
// only to multiply them and to move a point, so it carries its own rather than
|
||||||
|
// depending on a drawing library it has no other use for.
|
||||||
|
type matrix struct{ a, b, c, d, e, f float64 }
|
||||||
|
|
||||||
|
// identity is the transform that changes nothing.
|
||||||
|
var identity = matrix{1, 0, 0, 1, 0, 0}
|
||||||
|
|
||||||
|
// mul is the transform that applies m first and then n, which is the order a
|
||||||
|
// PDF composes them in.
|
||||||
|
func (m matrix) mul(n matrix) matrix {
|
||||||
|
return matrix{
|
||||||
|
a: m.a*n.a + m.b*n.c,
|
||||||
|
b: m.a*n.b + m.b*n.d,
|
||||||
|
c: m.c*n.a + m.d*n.c,
|
||||||
|
d: m.c*n.b + m.d*n.d,
|
||||||
|
e: m.e*n.a + m.f*n.c + n.e,
|
||||||
|
f: m.e*n.b + m.f*n.d + n.f,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply moves a point.
|
||||||
|
func (m matrix) apply(x, y float64) (float64, float64) {
|
||||||
|
return m.a*x + m.c*y + m.e, m.b*x + m.d*y + m.f
|
||||||
|
}
|
||||||
|
|
||||||
|
// scale is how much the transform stretches a length, taken as the geometric
|
||||||
|
// mean of its two axes so that a rotation counts as no stretch at all.
|
||||||
|
func (m matrix) scale() float64 {
|
||||||
|
x := m.a*m.a + m.b*m.b
|
||||||
|
y := m.c*m.c + m.d*m.d
|
||||||
|
return sqrt(sqrt(x * y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqrt without pulling in the whole of math for one use.
|
||||||
|
func sqrt(v float64) float64 {
|
||||||
|
if v <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
x := v
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
x = (x + v/x) / 2
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// A textState is everything the showing operators read besides the string.
|
||||||
|
type textState struct {
|
||||||
|
font *pdffont.Font
|
||||||
|
fontName string
|
||||||
|
size float64
|
||||||
|
charSpace float64
|
||||||
|
wordSpace float64
|
||||||
|
scale float64 // horizontal scaling, as a fraction
|
||||||
|
leading float64
|
||||||
|
rise float64
|
||||||
|
mode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// A state is the graphics state, of which extraction needs only the transform
|
||||||
|
// and the text parameters.
|
||||||
|
type state struct {
|
||||||
|
ctm matrix
|
||||||
|
text textState
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialState is the transform a page starts in, which for extraction is the
|
||||||
|
// one that undoes the page's own box: text comes back in the page's
|
||||||
|
// coordinates, counting up from the bottom left of what is visible.
|
||||||
|
func initialState(d *reader.Document, page reader.Dict) state {
|
||||||
|
box := pageBox(d, page)
|
||||||
|
return state{
|
||||||
|
ctm: matrix{1, 0, 0, 1, -box[0], -box[1]},
|
||||||
|
text: textState{scale: 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageBox is the area of the page that is visible.
|
||||||
|
func pageBox(d *reader.Document, page reader.Dict) [4]float64 {
|
||||||
|
for _, key := range []reader.Name{"CropBox", "MediaBox"} {
|
||||||
|
if b, ok := rectangle(d, page.Get(key)); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [4]float64{0, 0, 612, 792}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rectangle reads a PDF rectangle, put the right way round.
|
||||||
|
func rectangle(d *reader.Document, o reader.Object) ([4]float64, bool) {
|
||||||
|
var out [4]float64
|
||||||
|
arr, ok := reader.ToArray(resolve(d, o))
|
||||||
|
if !ok || len(arr) < 4 {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
v, ok := reader.ToFloat(resolve(d, arr[i]))
|
||||||
|
if !ok {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
if out[0] > out[2] {
|
||||||
|
out[0], out[2] = out[2], out[0]
|
||||||
|
}
|
||||||
|
if out[1] > out[3] {
|
||||||
|
out[1], out[3] = out[3], out[1]
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve follows an indirect reference. A document that opened cannot fail to
|
||||||
|
// resolve one.
|
||||||
|
func resolve(d *reader.Document, o reader.Object) reader.Object {
|
||||||
|
out, _ := d.Resolve(o)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// numbers reads the operands that are numbers.
|
||||||
|
func numbers(operands []reader.Object) []float64 {
|
||||||
|
out := make([]float64, 0, len(operands))
|
||||||
|
for _, o := range operands {
|
||||||
|
if v, ok := reader.ToFloat(o); ok {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
coverage.out
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026 the go-pdfkit/render authors
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
# pdffont
|
||||||
|
|
||||||
|
[](https://github.com/go-pdfkit/pdffont/actions/workflows/ci.yml)
|
||||||
|
[](https://pkg.go.dev/github.com/go-pdfkit/pdffont)
|
||||||
|
[](LICENSE)
|
||||||
|
[](#how-it-is-checked)
|
||||||
|
|
||||||
|
The PDF side of a font: not the outlines, which are the font program's
|
||||||
|
business, but everything the document itself says about how a string of
|
||||||
|
bytes is to be read.
|
||||||
|
|
||||||
|
Which codes a string holds, how wide each one is, what each is called, and
|
||||||
|
what text each stands for. It is what a renderer needs before it can ask a
|
||||||
|
font program for a glyph, and all an extractor needs to read a page back as
|
||||||
|
words — so neither has to say it twice.
|
||||||
|
|
||||||
|
Pure Go, no C, nothing outside [`reader`](https://github.com/go-pdfkit/reader)
|
||||||
|
and the standard library.
|
||||||
|
|
||||||
|
## What it reads
|
||||||
|
|
||||||
|
Simple fonts, addressed one byte at a time through a base encoding and
|
||||||
|
whatever differences the document names on top of it. Composite fonts,
|
||||||
|
addressed by character identifier, with their widths and their map from
|
||||||
|
identifier to glyph. Type 3 fonts, whose glyphs are little drawings and
|
||||||
|
whose widths are in a space of their own.
|
||||||
|
|
||||||
|
And `/ToUnicode`, which is the one thing in a PDF that says what its text
|
||||||
|
actually **says**. Without it a page can be drawn but not read.
|
||||||
|
|
||||||
|
## What it refuses to guess
|
||||||
|
|
||||||
|
A font that says it is **symbolic** is addressed through its own character
|
||||||
|
map, and a base encoding is a guess at it — a bad one. A mathematical font
|
||||||
|
puts a capital gamma where the standard encoding puts an inverted
|
||||||
|
exclamation mark, so reading the name gives the wrong letter with nothing
|
||||||
|
to say so.
|
||||||
|
|
||||||
|
Such a code is left unread unless the document named it itself, or unless a
|
||||||
|
caller installs [`Font.SetFallback`](https://pkg.go.dev/github.com/go-pdfkit/pdffont#Font.SetFallback)
|
||||||
|
to say what the embedded program calls it. Reading the program is another
|
||||||
|
package's work; `extract` does it with
|
||||||
|
[`go-opentype`](https://github.com/go-opentype/opentype).
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
100% of statements, including every branch that handles a dictionary saying
|
||||||
|
something it should not.
|
||||||
|
|
||||||
|
And the glyph-name table — which is the one piece of data here, and where a
|
||||||
|
single wrong entry would silently give the wrong letter — is held up
|
||||||
|
against the documents themselves. A font that both names a code and carries
|
||||||
|
a `/ToUnicode` entry for it states the same fact twice, so the two can be
|
||||||
|
compared. Over 12 000 corpus files: **63 515 names checked, 63 074 agreed —
|
||||||
|
99.31%**. The disagreements are producers whose own maps are wrong
|
||||||
|
(`alpha` → `↵`, `ff` → `!`), plus `Delta` and `Omega`, which Adobe's list
|
||||||
|
gives as the increment and ohm signs; the Greek letters written here are
|
||||||
|
the same characters by Unicode's own equivalence, and the ones a person
|
||||||
|
searching would type.
|
||||||
|
|
||||||
|
## The rest of the family
|
||||||
|
|
||||||
|
[**reader**](https://github.com/go-pdfkit/reader) reads and writes the format ·
|
||||||
|
[**ops**](https://github.com/go-pdfkit/ops) is the verbs and the `pdfops` command ·
|
||||||
|
[**render**](https://github.com/go-pdfkit/render) turns a page into pixels ·
|
||||||
|
[**pdffont**](https://github.com/go-pdfkit/pdffont) is what a document says about a font ·
|
||||||
|
[**extract**](https://github.com/go-pdfkit/extract) reads a page back ·
|
||||||
|
[**coedit**](https://github.com/go-pdfkit/coedit) is a PDF several people edit at once ·
|
||||||
|
[**app**](https://github.com/go-pdfkit/app) is the browser workbench.
|
||||||
|
|
||||||
|
Documentation for all of it: <https://go-pdfkit.github.io/docs/>
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
BSD-3-Clause.
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
package pdffont
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// The two encodings a simple font is addressed through when it does not carry
|
||||||
|
// one of its own, as a name per code. Only the codes that name a glyph appear;
|
||||||
|
// the rest are blank.
|
||||||
|
//
|
||||||
|
// These are the tables the specification prints. They matter because a font
|
||||||
|
// program is addressed by glyph name or by character, and a content stream is
|
||||||
|
// written in single bytes: without them, every byte would have to be guessed
|
||||||
|
// at.
|
||||||
|
|
||||||
|
// StandardEncoding is Adobe's own, which most Type 1 fonts assume, and what a
|
||||||
|
// simple font is read with when it says nothing else.
|
||||||
|
var StandardEncoding = [256]string{
|
||||||
|
32: "space", 33: "exclam", 34: "quotedbl", 35: "numbersign", 36: "dollar",
|
||||||
|
37: "percent", 38: "ampersand", 39: "quoteright", 40: "parenleft",
|
||||||
|
41: "parenright", 42: "asterisk", 43: "plus", 44: "comma", 45: "hyphen",
|
||||||
|
46: "period", 47: "slash", 48: "zero", 49: "one", 50: "two", 51: "three",
|
||||||
|
52: "four", 53: "five", 54: "six", 55: "seven", 56: "eight", 57: "nine",
|
||||||
|
58: "colon", 59: "semicolon", 60: "less", 61: "equal", 62: "greater",
|
||||||
|
63: "question", 64: "at", 65: "A", 66: "B", 67: "C", 68: "D", 69: "E",
|
||||||
|
70: "F", 71: "G", 72: "H", 73: "I", 74: "J", 75: "K", 76: "L", 77: "M",
|
||||||
|
78: "N", 79: "O", 80: "P", 81: "Q", 82: "R", 83: "S", 84: "T", 85: "U",
|
||||||
|
86: "V", 87: "W", 88: "X", 89: "Y", 90: "Z", 91: "bracketleft",
|
||||||
|
92: "backslash", 93: "bracketright", 94: "asciicircum", 95: "underscore",
|
||||||
|
96: "quoteleft", 97: "a", 98: "b", 99: "c", 100: "d", 101: "e", 102: "f",
|
||||||
|
103: "g", 104: "h", 105: "i", 106: "j", 107: "k", 108: "l", 109: "m",
|
||||||
|
110: "n", 111: "o", 112: "p", 113: "q", 114: "r", 115: "s", 116: "t",
|
||||||
|
117: "u", 118: "v", 119: "w", 120: "x", 121: "y", 122: "z",
|
||||||
|
123: "braceleft", 124: "bar", 125: "braceright", 126: "asciitilde",
|
||||||
|
161: "exclamdown", 162: "cent", 163: "sterling", 164: "fraction",
|
||||||
|
165: "yen", 166: "florin", 167: "section", 168: "currency",
|
||||||
|
169: "quotesingle", 170: "quotedblleft", 171: "guillemotleft",
|
||||||
|
172: "guilsinglleft", 173: "guilsinglright", 174: "fi", 175: "fl",
|
||||||
|
177: "endash", 178: "dagger", 179: "daggerdbl", 180: "periodcentered",
|
||||||
|
182: "paragraph", 183: "bullet", 184: "quotesinglbase",
|
||||||
|
185: "quotedblbase", 186: "quotedblright", 187: "guillemotright",
|
||||||
|
188: "ellipsis", 189: "perthousand", 191: "questiondown", 193: "grave",
|
||||||
|
194: "acute", 195: "circumflex", 196: "tilde", 197: "macron", 198: "breve",
|
||||||
|
199: "dotaccent", 200: "dieresis", 202: "ring", 203: "cedilla",
|
||||||
|
205: "hungarumlaut", 206: "ogonek", 207: "caron", 208: "emdash",
|
||||||
|
225: "AE", 227: "ordfeminine", 232: "Lslash", 233: "Oslash", 234: "OE",
|
||||||
|
235: "ordmasculine", 241: "ae", 245: "dotlessi", 248: "lslash",
|
||||||
|
249: "oslash", 250: "oe", 251: "germandbls",
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinAnsiEncoding is the one a file written on Windows assumes, and the one
|
||||||
|
// most producers name.
|
||||||
|
var WinAnsiEncoding = [256]string{
|
||||||
|
32: "space", 33: "exclam", 34: "quotedbl", 35: "numbersign", 36: "dollar",
|
||||||
|
37: "percent", 38: "ampersand", 39: "quotesingle", 40: "parenleft",
|
||||||
|
41: "parenright", 42: "asterisk", 43: "plus", 44: "comma", 45: "hyphen",
|
||||||
|
46: "period", 47: "slash", 48: "zero", 49: "one", 50: "two", 51: "three",
|
||||||
|
52: "four", 53: "five", 54: "six", 55: "seven", 56: "eight", 57: "nine",
|
||||||
|
58: "colon", 59: "semicolon", 60: "less", 61: "equal", 62: "greater",
|
||||||
|
63: "question", 64: "at", 65: "A", 66: "B", 67: "C", 68: "D", 69: "E",
|
||||||
|
70: "F", 71: "G", 72: "H", 73: "I", 74: "J", 75: "K", 76: "L", 77: "M",
|
||||||
|
78: "N", 79: "O", 80: "P", 81: "Q", 82: "R", 83: "S", 84: "T", 85: "U",
|
||||||
|
86: "V", 87: "W", 88: "X", 89: "Y", 90: "Z", 91: "bracketleft",
|
||||||
|
92: "backslash", 93: "bracketright", 94: "asciicircum", 95: "underscore",
|
||||||
|
96: "grave", 97: "a", 98: "b", 99: "c", 100: "d", 101: "e", 102: "f",
|
||||||
|
103: "g", 104: "h", 105: "i", 106: "j", 107: "k", 108: "l", 109: "m",
|
||||||
|
110: "n", 111: "o", 112: "p", 113: "q", 114: "r", 115: "s", 116: "t",
|
||||||
|
117: "u", 118: "v", 119: "w", 120: "x", 121: "y", 122: "z",
|
||||||
|
123: "braceleft", 124: "bar", 125: "braceright", 126: "asciitilde",
|
||||||
|
128: "Euro", 130: "quotesinglbase", 131: "florin", 132: "quotedblbase",
|
||||||
|
133: "ellipsis", 134: "dagger", 135: "daggerdbl", 136: "circumflex",
|
||||||
|
137: "perthousand", 138: "Scaron", 139: "guilsinglleft", 140: "OE",
|
||||||
|
142: "Zcaron", 145: "quoteleft", 146: "quoteright", 147: "quotedblleft",
|
||||||
|
148: "quotedblright", 149: "bullet", 150: "endash", 151: "emdash",
|
||||||
|
152: "tilde", 153: "trademark", 154: "scaron", 155: "guilsinglright",
|
||||||
|
156: "oe", 158: "zcaron", 159: "Ydieresis", 160: "space",
|
||||||
|
161: "exclamdown", 162: "cent", 163: "sterling", 164: "currency",
|
||||||
|
165: "yen", 166: "brokenbar", 167: "section", 168: "dieresis",
|
||||||
|
169: "copyright", 170: "ordfeminine", 171: "guillemotleft",
|
||||||
|
172: "logicalnot", 173: "hyphen", 174: "registered", 175: "macron",
|
||||||
|
176: "degree", 177: "plusminus", 178: "twosuperior",
|
||||||
|
179: "threesuperior", 180: "acute", 181: "mu", 182: "paragraph",
|
||||||
|
183: "periodcentered", 184: "cedilla", 185: "onesuperior",
|
||||||
|
186: "ordmasculine", 187: "guillemotright", 188: "onequarter",
|
||||||
|
189: "onehalf", 190: "threequarters", 191: "questiondown", 192: "Agrave",
|
||||||
|
193: "Aacute", 194: "Acircumflex", 195: "Atilde", 196: "Adieresis",
|
||||||
|
197: "Aring", 198: "AE", 199: "Ccedilla", 200: "Egrave", 201: "Eacute",
|
||||||
|
202: "Ecircumflex", 203: "Edieresis", 204: "Igrave", 205: "Iacute",
|
||||||
|
206: "Icircumflex", 207: "Idieresis", 208: "Eth", 209: "Ntilde",
|
||||||
|
210: "Ograve", 211: "Oacute", 212: "Ocircumflex", 213: "Otilde",
|
||||||
|
214: "Odieresis", 215: "multiply", 216: "Oslash", 217: "Ugrave",
|
||||||
|
218: "Uacute", 219: "Ucircumflex", 220: "Udieresis", 221: "Yacute",
|
||||||
|
222: "Thorn", 223: "germandbls", 224: "agrave", 225: "aacute",
|
||||||
|
226: "acircumflex", 227: "atilde", 228: "adieresis", 229: "aring",
|
||||||
|
230: "ae", 231: "ccedilla", 232: "egrave", 233: "eacute",
|
||||||
|
234: "ecircumflex", 235: "edieresis", 236: "igrave", 237: "iacute",
|
||||||
|
238: "icircumflex", 239: "idieresis", 240: "eth", 241: "ntilde",
|
||||||
|
242: "ograve", 243: "oacute", 244: "ocircumflex", 245: "otilde",
|
||||||
|
246: "odieresis", 247: "divide", 248: "oslash", 249: "ugrave",
|
||||||
|
250: "uacute", 251: "ucircumflex", 252: "udieresis", 253: "yacute",
|
||||||
|
254: "thorn", 255: "ydieresis",
|
||||||
|
}
|
||||||
|
|
||||||
|
// glyphRunes is what the names above stand for. A font program is looked up by
|
||||||
|
// character, so a name has to become one; anything not here, and not written
|
||||||
|
// in one of the two conventional forms, cannot be looked up that way.
|
||||||
|
var glyphRunes = map[string]rune{
|
||||||
|
"space": ' ', "exclam": '!', "quotedbl": '"', "numbersign": '#',
|
||||||
|
"dollar": '$', "percent": '%', "ampersand": '&', "quotesingle": '\'',
|
||||||
|
"quoteright": '’', "quoteleft": '‘', "parenleft": '(',
|
||||||
|
"parenright": ')', "asterisk": '*', "plus": '+', "comma": ',',
|
||||||
|
"hyphen": '-', "period": '.', "slash": '/', "zero": '0', "one": '1',
|
||||||
|
"two": '2', "three": '3', "four": '4', "five": '5', "six": '6',
|
||||||
|
"seven": '7', "eight": '8', "nine": '9', "colon": ':', "semicolon": ';',
|
||||||
|
"less": '<', "equal": '=', "greater": '>', "question": '?', "at": '@',
|
||||||
|
"bracketleft": '[', "backslash": '\\', "bracketright": ']',
|
||||||
|
"asciicircum": '^', "underscore": '_', "braceleft": '{', "bar": '|',
|
||||||
|
"braceright": '}', "asciitilde": '~', "exclamdown": '¡', "cent": '¢',
|
||||||
|
"sterling": '£', "fraction": '⁄', "yen": '¥', "florin": 'ƒ',
|
||||||
|
"section": '§', "currency": '¤', "quotedblleft": '“',
|
||||||
|
"quotedblright": '”', "guillemotleft": '«', "guillemotright": '»',
|
||||||
|
"guilsinglleft": '‹', "guilsinglright": '›', "fi": 'fi', "fl": 'fl',
|
||||||
|
"endash": '–', "emdash": '—', "dagger": '†', "daggerdbl": '‡',
|
||||||
|
"periodcentered": '·', "paragraph": '¶', "bullet": '•',
|
||||||
|
"quotesinglbase": '‚', "quotedblbase": '„', "ellipsis": '…',
|
||||||
|
"perthousand": '‰', "questiondown": '¿', "grave": '`', "acute": '´',
|
||||||
|
"circumflex": 'ˆ', "tilde": '˜', "macron": '¯', "breve": '˘',
|
||||||
|
"dotaccent": '˙', "dieresis": '¨', "ring": '˚', "cedilla": '¸',
|
||||||
|
"hungarumlaut": '˝', "ogonek": '˛', "caron": 'ˇ', "AE": 'Æ', "ae": 'æ',
|
||||||
|
"ordfeminine": 'ª', "ordmasculine": 'º', "Lslash": 'Ł', "lslash": 'ł',
|
||||||
|
"Oslash": 'Ø', "oslash": 'ø', "OE": 'Œ', "oe": 'œ', "dotlessi": 'ı',
|
||||||
|
"germandbls": 'ß', "Euro": '€', "Scaron": 'Š', "scaron": 'š',
|
||||||
|
"Zcaron": 'Ž', "zcaron": 'ž', "Ydieresis": 'Ÿ', "trademark": '™',
|
||||||
|
"brokenbar": '¦', "copyright": '©', "logicalnot": '¬', "registered": '®',
|
||||||
|
"degree": '°', "plusminus": '±', "twosuperior": '²',
|
||||||
|
"threesuperior": '³', "mu": 'µ', "onesuperior": '¹', "onequarter": '¼',
|
||||||
|
"onehalf": '½', "threequarters": '¾', "multiply": '×', "divide": '÷',
|
||||||
|
"Agrave": 'À', "Aacute": 'Á', "Acircumflex": 'Â', "Atilde": 'Ã',
|
||||||
|
"Adieresis": 'Ä', "Aring": 'Å', "Ccedilla": 'Ç', "Egrave": 'È',
|
||||||
|
"Eacute": 'É', "Ecircumflex": 'Ê', "Edieresis": 'Ë', "Igrave": 'Ì',
|
||||||
|
"Iacute": 'Í', "Icircumflex": 'Î', "Idieresis": 'Ï', "Eth": 'Ð',
|
||||||
|
"Ntilde": 'Ñ', "Ograve": 'Ò', "Oacute": 'Ó', "Ocircumflex": 'Ô',
|
||||||
|
"Otilde": 'Õ', "Odieresis": 'Ö', "Ugrave": 'Ù', "Uacute": 'Ú',
|
||||||
|
"Ucircumflex": 'Û', "Udieresis": 'Ü', "Yacute": 'Ý', "Thorn": 'Þ',
|
||||||
|
"agrave": 'à', "aacute": 'á', "acircumflex": 'â', "atilde": 'ã',
|
||||||
|
"adieresis": 'ä', "aring": 'å', "ccedilla": 'ç', "egrave": 'è',
|
||||||
|
"eacute": 'é', "ecircumflex": 'ê', "edieresis": 'ë', "igrave": 'ì',
|
||||||
|
"iacute": 'í', "icircumflex": 'î', "idieresis": 'ï', "eth": 'ð',
|
||||||
|
"ntilde": 'ñ', "ograve": 'ò', "oacute": 'ó', "ocircumflex": 'ô',
|
||||||
|
"otilde": 'õ', "odieresis": 'ö', "ugrave": 'ù', "uacute": 'ú',
|
||||||
|
"ucircumflex": 'û', "udieresis": 'ü', "yacute": 'ý', "thorn": 'þ',
|
||||||
|
"ydieresis": 'ÿ',
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuneOfGlyphName turns a glyph name into the character it stands for, through
|
||||||
|
// the table above or through the two conventions the specification defines for
|
||||||
|
// naming a character directly. ok is false for a name that says nothing about
|
||||||
|
// which character it is, which a subsetted font's own names often do not.
|
||||||
|
func RuneOfGlyphName(name string) (rune, bool) {
|
||||||
|
s, ok := TextOfGlyphName(name)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) != 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return r[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextOfGlyphName turns a glyph name into the text it stands for, which is not
|
||||||
|
// always one character: a name may say it is a ligature of several.
|
||||||
|
//
|
||||||
|
// Three of the rules here are the ones the Adobe Glyph List lays down for
|
||||||
|
// reading a name nobody has listed, and each was costing real text. A name may
|
||||||
|
// carry a variant after a full stop — "a.sc" is a small-capital A and is still
|
||||||
|
// an A, 7 272 of them in the corpus. A name may be several component names
|
||||||
|
// with underscores between them — "f_i" is the fi ligature, and without this
|
||||||
|
// "Definition" comes back "Denition", which is not a missing character so much
|
||||||
|
// as a misspelt word. And a name may simply be one nobody thought to list.
|
||||||
|
func TextOfGlyphName(name string) (string, bool) {
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if r, ok := oneGlyphRune(name); ok {
|
||||||
|
return string(r), true
|
||||||
|
}
|
||||||
|
if seq, ok := unicodeSequence(name); ok {
|
||||||
|
return seq, true
|
||||||
|
}
|
||||||
|
// A variant of a character is still that character: the part before the
|
||||||
|
// first full stop is the name, and what follows says which cut of it.
|
||||||
|
if base, _, found := strings.Cut(name, "."); found {
|
||||||
|
if base == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return TextOfGlyphName(base)
|
||||||
|
}
|
||||||
|
// A name made of parts with underscores between them is the parts, in
|
||||||
|
// order — which is how a ligature is named when it has no name of its own.
|
||||||
|
if strings.Contains(name, "_") {
|
||||||
|
// Every part that can be read contributes at least one character, and
|
||||||
|
// a part that cannot gives up on the whole name, so what comes out of
|
||||||
|
// this is never empty.
|
||||||
|
var out strings.Builder
|
||||||
|
for _, part := range strings.Split(name, "_") {
|
||||||
|
piece, ok := TextOfGlyphName(part)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
out.WriteString(piece)
|
||||||
|
}
|
||||||
|
return out.String(), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneGlyphRune is a name that stands for exactly one character, by any of the
|
||||||
|
// ways a name can.
|
||||||
|
func oneGlyphRune(name string) (rune, bool) {
|
||||||
|
if r, ok := glyphRunes[name]; ok {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
if r, ok := latinExtendedRunes[name]; ok {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
if r, ok := greekAndMathRunes[name]; ok {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
if len(name) == 1 {
|
||||||
|
return rune(name[0]), true
|
||||||
|
}
|
||||||
|
if r, ok := parseHexName(name, "uni", 4); ok {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
if r, ok := parseHexName(name, "u", 4); ok {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// unicodeSequence reads the uniXXXXYYYY form, which names several characters
|
||||||
|
// at once: a letter and the accent that goes over it, or the three pieces a
|
||||||
|
// Hebrew cluster is written in.
|
||||||
|
//
|
||||||
|
// It has to be told apart from something that looks exactly like it. Producers
|
||||||
|
// write a glyph's own number in the same shape — uni00000048 is glyph 72, not
|
||||||
|
// U+0000 followed by U+0048 — and 49 740 names in the corpus are that. The two
|
||||||
|
// are separable by one observation: a real sequence never begins with U+0000,
|
||||||
|
// because nothing is written after a character that does not exist. Of the
|
||||||
|
// 3 875 genuine sequences found across 14 823 embedded fonts, not one starts
|
||||||
|
// with a zero group; of the disguised glyph numbers, all of them do.
|
||||||
|
func unicodeSequence(name string) (string, bool) {
|
||||||
|
digits, ok := strings.CutPrefix(name, "uni")
|
||||||
|
if !ok || len(digits) < 8 || len(digits)%4 != 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for i := 0; i < len(digits); i += 4 {
|
||||||
|
r, ok := parseHexName("uni"+digits[i:i+4], "uni", 4)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
// A glyph number wearing a character's clothes.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
out.WriteRune(r)
|
||||||
|
}
|
||||||
|
return out.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHexName reads the uniXXXX and uXXXX conventions.
|
||||||
|
func parseHexName(name, prefix string, minDigits int) (rune, bool) {
|
||||||
|
if len(name) <= len(prefix) || name[:len(prefix)] != prefix {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
digits := name[len(prefix):]
|
||||||
|
if len(digits) < minDigits || len(digits) > 6 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var v rune
|
||||||
|
for i := 0; i < len(digits); i++ {
|
||||||
|
d := hexDigit(digits[i])
|
||||||
|
if d < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v = v<<4 | rune(d)
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexDigit reads one hexadecimal digit, or reports -1.
|
||||||
|
func hexDigit(c byte) int {
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
return int(c - '0')
|
||||||
|
case c >= 'A' && c <= 'F':
|
||||||
|
return int(c-'A') + 10
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
return int(c-'a') + 10
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package pdffont
|
||||||
|
|
||||||
|
// The glyph names above cover the Latin every English document is set in and
|
||||||
|
// stop there. A document in Czech, Polish, Slovak, Hungarian, Turkish,
|
||||||
|
// Romanian, Latvian, Lithuanian or Maltese is set in the block that follows,
|
||||||
|
// and every one of its accented letters was being read as nothing at all —
|
||||||
|
// which is worse than it sounds, because a dropped character leaves no mark:
|
||||||
|
// "Příliš" comes back "Píliš" and looks like a word.
|
||||||
|
//
|
||||||
|
// Counted across the 118 833-file corpus: of 52.4 million glyph names, 483 382
|
||||||
|
// could not be turned into text, and the largest nameable share of those was
|
||||||
|
// this block — rcaron 650, zdotaccent 617, Ccaron 611, nacute 602, aogonek
|
||||||
|
// 602, scedilla 597, gbreve 597, and so on for a hundred more.
|
||||||
|
//
|
||||||
|
// These are the names the Adobe Glyph List gives, and the characters are not
|
||||||
|
// in doubt: unlike the subsetted fonts that call their glyphs g0 or dress a
|
||||||
|
// glyph number up as uni00000048, a name like "rcaron" says exactly one thing.
|
||||||
|
var latinExtendedRunes = map[string]rune{
|
||||||
|
"Amacron": 'Ā', "amacron": 'ā', "Abreve": 'Ă', "abreve": 'ă',
|
||||||
|
"Aogonek": 'Ą', "aogonek": 'ą', "Cacute": 'Ć', "cacute": 'ć',
|
||||||
|
"Ccircumflex": 'Ĉ', "ccircumflex": 'ĉ', "Cdotaccent": 'Ċ', "cdotaccent": 'ċ',
|
||||||
|
"Ccaron": 'Č', "ccaron": 'č', "Dcaron": 'Ď', "dcaron": 'ď',
|
||||||
|
"Dcroat": 'Đ', "dcroat": 'đ', "Dslash": 'Đ', "dmacron": 'đ',
|
||||||
|
"Emacron": 'Ē', "emacron": 'ē', "Ebreve": 'Ĕ', "ebreve": 'ĕ',
|
||||||
|
"Edotaccent": 'Ė', "edotaccent": 'ė', "Eogonek": 'Ę', "eogonek": 'ę',
|
||||||
|
"Ecaron": 'Ě', "ecaron": 'ě', "Gcircumflex": 'Ĝ', "gcircumflex": 'ĝ',
|
||||||
|
"Gbreve": 'Ğ', "gbreve": 'ğ', "Gdotaccent": 'Ġ', "gdotaccent": 'ġ',
|
||||||
|
"Gcommaaccent": 'Ģ', "gcommaaccent": 'ģ',
|
||||||
|
"Hcircumflex": 'Ĥ', "hcircumflex": 'ĥ', "Hbar": 'Ħ', "hbar": 'ħ',
|
||||||
|
"Itilde": 'Ĩ', "itilde": 'ĩ', "Imacron": 'Ī', "imacron": 'ī',
|
||||||
|
"Ibreve": 'Ĭ', "ibreve": 'ĭ', "Iogonek": 'Į', "iogonek": 'į',
|
||||||
|
"Idotaccent": 'İ', "dotlessi": 'ı', "IJ": 'IJ', "ij": 'ij',
|
||||||
|
"Jcircumflex": 'Ĵ', "jcircumflex": 'ĵ',
|
||||||
|
"Kcommaaccent": 'Ķ', "kcommaaccent": 'ķ', "kgreenlandic": 'ĸ',
|
||||||
|
"Lacute": 'Ĺ', "lacute": 'ĺ', "Lcommaaccent": 'Ļ', "lcommaaccent": 'ļ',
|
||||||
|
"Lcaron": 'Ľ', "lcaron": 'ľ', "Ldot": 'Ŀ', "ldot": 'ŀ',
|
||||||
|
"Nacute": 'Ń', "nacute": 'ń', "Ncommaaccent": 'Ņ', "ncommaaccent": 'ņ',
|
||||||
|
"Ncaron": 'Ň', "ncaron": 'ň', "napostrophe": 'ʼn', "Eng": 'Ŋ', "eng": 'ŋ',
|
||||||
|
"Omacron": 'Ō', "omacron": 'ō', "Obreve": 'Ŏ', "obreve": 'ŏ',
|
||||||
|
"Ohungarumlaut": 'Ő', "ohungarumlaut": 'ő',
|
||||||
|
"Racute": 'Ŕ', "racute": 'ŕ', "Rcommaaccent": 'Ŗ', "rcommaaccent": 'ŗ',
|
||||||
|
"Rcaron": 'Ř', "rcaron": 'ř', "Sacute": 'Ś', "sacute": 'ś',
|
||||||
|
"Scircumflex": 'Ŝ', "scircumflex": 'ŝ', "Scedilla": 'Ş', "scedilla": 'ş',
|
||||||
|
"Tcommaaccent": 'Ţ', "tcommaaccent": 'ţ', "Tcaron": 'Ť', "tcaron": 'ť',
|
||||||
|
"Tbar": 'Ŧ', "tbar": 'ŧ', "Utilde": 'Ũ', "utilde": 'ũ',
|
||||||
|
"Umacron": 'Ū', "umacron": 'ū', "Ubreve": 'Ŭ', "ubreve": 'ŭ',
|
||||||
|
"Uring": 'Ů', "uring": 'ů', "Uhungarumlaut": 'Ű', "uhungarumlaut": 'ű',
|
||||||
|
"Uogonek": 'Ų', "uogonek": 'ų', "Wcircumflex": 'Ŵ', "wcircumflex": 'ŵ',
|
||||||
|
"Ycircumflex": 'Ŷ', "ycircumflex": 'ŷ',
|
||||||
|
"Zacute": 'Ź', "zacute": 'ź', "Zdotaccent": 'Ż', "zdotaccent": 'ż',
|
||||||
|
"longs": 'ſ',
|
||||||
|
|
||||||
|
// A handful outside that block that the same documents use: the Romanian
|
||||||
|
// letters written with a comma below rather than a cedilla, the Turkish
|
||||||
|
// and Azeri schwa, and the two the Adobe list names for Welsh and Maltese.
|
||||||
|
"Scommaaccent": 'Ș', "scommaaccent": 'ș',
|
||||||
|
"Afii10017": 'А', "afii10017": 'А',
|
||||||
|
"Wgrave": 'Ẁ', "wgrave": 'ẁ', "Wacute": 'Ẃ', "wacute": 'ẃ',
|
||||||
|
"Wdieresis": 'Ẅ', "wdieresis": 'ẅ',
|
||||||
|
"Ygrave": 'Ỳ', "ygrave": 'ỳ',
|
||||||
|
"Hcaron": 'Ȟ', "hcaron": 'ȟ',
|
||||||
|
// The rest of what the corpus asks for and nothing could answer, each of
|
||||||
|
// them a name the Adobe list gives and none of them in doubt: the cedilla
|
||||||
|
// spellings of the Romanian letters, the capital sharp s, the script ell
|
||||||
|
// mathematicians write lengths with, and the angle brackets.
|
||||||
|
"Tcedilla": 'Ţ', "tcedilla": 'ţ', "Germandbls": 'ẞ',
|
||||||
|
"lscript": 'ℓ', "openbullet": '◦',
|
||||||
|
"angbracketleft": '〈', "angbracketright": '〉',
|
||||||
|
"Scommabelow": 'Ș', "scommabelow": 'ș',
|
||||||
|
"Tcommabelow": 'Ț', "tcommabelow": 'ț',
|
||||||
|
}
|
||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
// Package pdffont is the PDF side of a font: not the outlines, which are the
|
||||||
|
// font program's business, but everything the document itself says about how a
|
||||||
|
// string of bytes is to be read. Which codes a string holds, how wide each one
|
||||||
|
// is, what each is called, and what text each stands for.
|
||||||
|
//
|
||||||
|
// It is what a renderer needs before it can ask a font program for a glyph,
|
||||||
|
// and all an extractor needs to read a page back as words. Both would
|
||||||
|
// otherwise have to say the same things twice.
|
||||||
|
package pdffont
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Kind says how a font's codes are read.
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Simple fonts take one byte a code and are addressed by glyph name.
|
||||||
|
Simple Kind = iota
|
||||||
|
// Composite fonts take a character identifier, usually two bytes wide.
|
||||||
|
Composite
|
||||||
|
// Type3 fonts draw each glyph by running a little content stream.
|
||||||
|
Type3
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Font is a font as the document describes it.
|
||||||
|
type Font struct {
|
||||||
|
kind Kind
|
||||||
|
dict reader.Dict
|
||||||
|
descriptor reader.Dict
|
||||||
|
doc *reader.Document
|
||||||
|
|
||||||
|
names map[int]string // what each code is called, for a simple font
|
||||||
|
// chosen says which of those names the document chose itself, in a
|
||||||
|
// Differences array, rather than inheriting from a base encoding.
|
||||||
|
chosen map[int]bool
|
||||||
|
widths map[int]float64 // in text space: a thousandth of the point size
|
||||||
|
defaultW float64
|
||||||
|
toUni map[int]string
|
||||||
|
symbolic bool
|
||||||
|
|
||||||
|
// fallback is a last resort for codes the document itself says nothing
|
||||||
|
// about: what the embedded font program calls them. A caller that can
|
||||||
|
// read the program installs one.
|
||||||
|
fallback func(code int) (string, bool)
|
||||||
|
|
||||||
|
// cidToGID maps a character identifier to a glyph number, for a composite
|
||||||
|
// font whose descendant carries a map rather than using them as they are.
|
||||||
|
cidToGID []byte
|
||||||
|
|
||||||
|
// The pieces only a Type 3 font has.
|
||||||
|
fontMatrix [6]float64
|
||||||
|
charProcs reader.Dict
|
||||||
|
t3Res reader.Dict
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read takes what a font dictionary says. It never fails: a dictionary that
|
||||||
|
// says nothing useful gives a font that reads its codes one byte at a time and
|
||||||
|
// advances by half an em, which is what a reader has to do with one anyway.
|
||||||
|
func Read(d *reader.Document, dict reader.Dict) *Font {
|
||||||
|
f := &Font{
|
||||||
|
doc: d, dict: dict, widths: map[int]float64{}, defaultW: 0.5,
|
||||||
|
fontMatrix: [6]float64{0.001, 0, 0, 0.001, 0, 0},
|
||||||
|
}
|
||||||
|
switch sub, _ := reader.ToName(resolve(d, dict.Get("Subtype"))); sub {
|
||||||
|
case "Type0":
|
||||||
|
f.kind = Composite
|
||||||
|
f.readComposite()
|
||||||
|
case "Type3":
|
||||||
|
f.kind = Type3
|
||||||
|
f.readType3()
|
||||||
|
default:
|
||||||
|
f.kind = Simple
|
||||||
|
f.readSimple()
|
||||||
|
}
|
||||||
|
f.toUni = f.readToUnicode()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind says how this font's codes are read.
|
||||||
|
func (f *Font) Kind() Kind { return f.kind }
|
||||||
|
|
||||||
|
// Dict is the font dictionary itself, for whatever a caller needs that this
|
||||||
|
// does not say.
|
||||||
|
func (f *Font) Dict() reader.Dict { return f.dict }
|
||||||
|
|
||||||
|
// Descriptor is the font descriptor, which is where an embedded program lives.
|
||||||
|
func (f *Font) Descriptor() reader.Dict { return f.descriptor }
|
||||||
|
|
||||||
|
// Symbolic reports a font that says it is addressed through its own character
|
||||||
|
// map rather than through an encoding of names.
|
||||||
|
func (f *Font) Symbolic() bool { return f.symbolic }
|
||||||
|
|
||||||
|
// FontMatrix is how a Type 3 font's glyph space relates to text space. For
|
||||||
|
// every other kind it is a thousandth, which is what the format assumes.
|
||||||
|
func (f *Font) FontMatrix() [6]float64 { return f.fontMatrix }
|
||||||
|
|
||||||
|
// CharProcs are a Type 3 font's glyph drawings, by name.
|
||||||
|
func (f *Font) CharProcs() reader.Dict { return f.charProcs }
|
||||||
|
|
||||||
|
// Type3Resources are what a Type 3 font's glyph drawings draw with.
|
||||||
|
func (f *Font) Type3Resources() reader.Dict { return f.t3Res }
|
||||||
|
|
||||||
|
// Codes cuts a string into the codes it holds: one byte at a time for a simple
|
||||||
|
// font, two for a composite one, which is what every composite encoding seen in
|
||||||
|
// the wild uses.
|
||||||
|
func (f *Font) Codes(s []byte) []int {
|
||||||
|
if f.kind != Composite {
|
||||||
|
out := make([]int, len(s))
|
||||||
|
for i, b := range s {
|
||||||
|
out[i] = int(b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := make([]int, 0, len(s)/2)
|
||||||
|
for i := 0; i+1 < len(s); i += 2 {
|
||||||
|
out = append(out, int(s[i])<<8|int(s[i+1]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width is how far the pen moves for one code, in text space — a multiple of
|
||||||
|
// the point size, so a width of half means half an em.
|
||||||
|
func (f *Font) Width(code int) float64 {
|
||||||
|
if w, ok := f.widths[code]; ok {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
return f.defaultW
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasWidth reports whether the document said how wide this code is, rather
|
||||||
|
// than the width being the fallback [Font.Width] gives for one it did not.
|
||||||
|
//
|
||||||
|
// It matters for a font the document does not carry. One of the fourteen
|
||||||
|
// standard faces is written with no widths at all — every reader is expected
|
||||||
|
// to know Times and Helvetica and Courier by heart — so a reader drawing a
|
||||||
|
// stand-in has to take the advances from the stand-in too. Half an em for
|
||||||
|
// every letter reads as a typewriter, which is not what the page says.
|
||||||
|
func (f *Font) HasWidth(code int) bool {
|
||||||
|
_, ok := f.widths[code]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphName is what the document's encoding calls a code. ok is false for a
|
||||||
|
// composite font, which names nothing, and for a code the encoding passes over.
|
||||||
|
func (f *Font) GlyphName(code int) (string, bool) {
|
||||||
|
name, ok := f.names[code]
|
||||||
|
return name, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text is what a code stands for as characters: what the font's ToUnicode map
|
||||||
|
// says, or failing that what its glyph name says. ok is false when neither
|
||||||
|
// does — a subsetted font naming its glyphs g17 and carrying no map cannot be
|
||||||
|
// read back, and saying so is better than guessing.
|
||||||
|
func (f *Font) Text(code int) (string, bool) {
|
||||||
|
if s, ok := f.toUni[code]; ok && s != "" {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
if name, ok := f.names[code]; ok && f.namedByTheDocument(code) {
|
||||||
|
// Not one character: a name may say it is a ligature of several, and
|
||||||
|
// a code that stands for two letters has to give back two.
|
||||||
|
if text, ok := TextOfGlyphName(name); ok {
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.fallback != nil {
|
||||||
|
if s, ok := f.fallback(code); ok && s != "" {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFallback installs a last resort for codes the document says nothing
|
||||||
|
// about: what the embedded font program calls them.
|
||||||
|
//
|
||||||
|
// A font that says it is symbolic is addressed through its own character map,
|
||||||
|
// and what that map says is the only honest answer for a code the document did
|
||||||
|
// not name. Reading the program is another package's work, so a caller that
|
||||||
|
// can do it hands the answer in here.
|
||||||
|
func (f *Font) SetFallback(fn func(code int) (string, bool)) { f.fallback = fn }
|
||||||
|
|
||||||
|
// namedByTheDocument reports whether a code's name can be believed as a
|
||||||
|
// statement about which character it is.
|
||||||
|
//
|
||||||
|
// A font that says it is symbolic is addressed through its own character map,
|
||||||
|
// and the base encoding is then a guess. It is a bad one: a mathematical font
|
||||||
|
// puts a capital gamma where the standard encoding puts an inverted
|
||||||
|
// exclamation mark, and reading the name would give the wrong letter with
|
||||||
|
// nothing to say so. A name the document chose itself, in a Differences
|
||||||
|
// array, is a different matter — that one it meant.
|
||||||
|
func (f *Font) namedByTheDocument(code int) bool {
|
||||||
|
return !f.symbolic || f.chosen[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chosen reports whether the document named this code itself rather than
|
||||||
|
// inheriting the name from a base encoding.
|
||||||
|
func (f *Font) Chosen(code int) bool { return f.chosen[code] }
|
||||||
|
|
||||||
|
// CIDToGID maps a character identifier to a glyph number for a composite font.
|
||||||
|
// ok is false when the font's map does not reach that far.
|
||||||
|
func (f *Font) CIDToGID(cid int) (int, bool) {
|
||||||
|
if f.cidToGID == nil {
|
||||||
|
return cid, true
|
||||||
|
}
|
||||||
|
i := cid * 2
|
||||||
|
if i+1 >= len(f.cidToGID) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(f.cidToGID[i])<<8 | int(f.cidToGID[i+1]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program is the embedded font program: which key it arrived under, and its
|
||||||
|
// bytes. ok is false for a font whose program is not embedded, which is every
|
||||||
|
// standard face and many others besides.
|
||||||
|
func (f *Font) Program() (reader.Name, []byte, bool) {
|
||||||
|
for _, key := range []reader.Name{"FontFile2", "FontFile3", "FontFile"} {
|
||||||
|
stream, ok := reader.ToStream(resolve(f.doc, f.descriptor.Get(key)))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, img, err := f.doc.DecodeStream(stream)
|
||||||
|
if err != nil || img != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return key, data, true
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve follows an indirect reference. A document that opened cannot fail to
|
||||||
|
// resolve one, so there is nothing to handle at every use.
|
||||||
|
func resolve(d *reader.Document, o reader.Object) reader.Object {
|
||||||
|
out, _ := d.Resolve(o)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// floatOf reads a number, or gives a default.
|
||||||
|
func floatOf(o reader.Object, def float64) float64 {
|
||||||
|
if v, ok := reader.ToFloat(o); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package pdffont
|
||||||
|
|
||||||
|
// greekAndMathRunes are the names a mathematical document gives its glyphs.
|
||||||
|
// They are not in the Latin encodings, so a page of equations addressed by
|
||||||
|
// name — which is what every TeX document is — could not be read back without
|
||||||
|
// them: the words came out and the symbols did not.
|
||||||
|
//
|
||||||
|
// The names are Adobe's, from the Symbol font and the glyph list every
|
||||||
|
// producer follows. They are checked against the documents themselves rather
|
||||||
|
// than taken on trust: a font that carries both a name for a code and a
|
||||||
|
// ToUnicode entry for it states the same fact twice, and the corpus has tens
|
||||||
|
// of thousands of such pairs to compare against.
|
||||||
|
var greekAndMathRunes = map[string]rune{
|
||||||
|
// The Greek alphabet, lower case and upper.
|
||||||
|
"alpha": 'α', "beta": 'β', "gamma": 'γ', "delta": 'δ', "epsilon": 'ε',
|
||||||
|
"zeta": 'ζ', "eta": 'η', "theta": 'θ', "iota": 'ι', "kappa": 'κ',
|
||||||
|
"lambda": 'λ', "mu1": 'μ', "nu": 'ν', "xi": 'ξ', "omicron": 'ο',
|
||||||
|
"pi": 'π', "rho": 'ρ', "sigma": 'σ', "sigma1": 'ς', "tau": 'τ',
|
||||||
|
"upsilon": 'υ', "phi": 'φ', "chi": 'χ', "psi": 'ψ', "omega": 'ω',
|
||||||
|
"theta1": 'ϑ', "phi1": 'ϕ', "epsilon1": 'ϵ', "rho1": 'ϱ', "omega1": 'ϖ',
|
||||||
|
"kappa1": 'ϰ', "pi1": 'ϖ',
|
||||||
|
// Adobe's list gives Delta and Omega the increment and ohm signs, which
|
||||||
|
// Unicode says are the same characters as the Greek letters written
|
||||||
|
// here — canonically equivalent, and these are the forms a person
|
||||||
|
// searching the text would type.
|
||||||
|
"Alpha": 'Α', "Beta": 'Β', "Gamma": 'Γ', "Delta": 'Δ', "Epsilon": 'Ε',
|
||||||
|
"Zeta": 'Ζ', "Eta": 'Η', "Theta": 'Θ', "Iota": 'Ι', "Kappa": 'Κ',
|
||||||
|
"Lambda": 'Λ', "Mu": 'Μ', "Nu": 'Ν', "Xi": 'Ξ', "Omicron": 'Ο',
|
||||||
|
"Pi": 'Π', "Rho": 'Ρ', "Sigma": 'Σ', "Tau": 'Τ', "Upsilon": 'Υ',
|
||||||
|
"Phi": 'Φ', "Chi": 'Χ', "Psi": 'Ψ', "Omega": 'Ω', "Upsilon1": 'ϒ',
|
||||||
|
|
||||||
|
// The signs an equation is built from.
|
||||||
|
"minus": '−', "plus": '+', "equal": '=', "asteriskmath": '∗',
|
||||||
|
"lessequal": '≤', "greaterequal": '≥', "notequal": '≠', "approxequal": '≈',
|
||||||
|
"equivalence": '≡', "similar": '∼', "congruent": '≅', "proportional": '∝',
|
||||||
|
"infinity": '∞', "partialdiff": '∂', "gradient": '∇', "integral": '∫',
|
||||||
|
"summation": '∑', "product": '∏', "radical": '√', "angle": '∠',
|
||||||
|
"perpendicular": '⊥', "therefore": '∴', "dotmath": '⋅', "bullet3": '∙',
|
||||||
|
"element": '∈', "notelement": '∉', "universal": '∀', "existential": '∃',
|
||||||
|
"emptyset": '∅', "intersection": '∩', "union": '∪',
|
||||||
|
"propersubset": '⊂', "propersuperset": '⊃',
|
||||||
|
"reflexsubset": '⊆', "reflexsuperset": '⊇', "notsubset": '⊄',
|
||||||
|
"logicaland": '∧', "logicalor": '∨', "logicalnot1": '¬',
|
||||||
|
"circleplus": '⊕', "circlemultiply": '⊗',
|
||||||
|
"prime": '′', "second": '″', "minute": '′',
|
||||||
|
|
||||||
|
// Arrows, and the brackets a display equation is built out of.
|
||||||
|
"arrowleft": '←', "arrowup": '↑', "arrowright": '→', "arrowdown": '↓',
|
||||||
|
"arrowboth": '↔', "arrowupdn": '↕',
|
||||||
|
"arrowdblleft": '⇐', "arrowdblup": '⇑', "arrowdblright": '⇒',
|
||||||
|
"arrowdbldown": '⇓', "arrowdblboth": '⇔',
|
||||||
|
"angleleft": '⟨', "angleright": '⟩',
|
||||||
|
"bracketlefttp": '⎡', "bracketleftbt": '⎣', "bracketleftex": '⎢',
|
||||||
|
"bracketrighttp": '⎤', "bracketrightbt": '⎦', "bracketrightex": '⎥',
|
||||||
|
"parenlefttp": '⎛', "parenleftbt": '⎝', "parenleftex": '⎜',
|
||||||
|
"parenrighttp": '⎞', "parenrightbt": '⎠', "parenrightex": '⎟',
|
||||||
|
"bracelefttp": '⎧', "braceleftmid": '⎨', "braceleftbt": '⎩',
|
||||||
|
"bracerighttp": '⎫', "bracerightmid": '⎬', "bracerightbt": '⎭',
|
||||||
|
"braceex": '⎪',
|
||||||
|
|
||||||
|
// The ligatures and marks a book is set with.
|
||||||
|
"ff": 'ff', "ffi": 'ffi', "ffl": 'ffl', "dotlessj": 'ȷ',
|
||||||
|
"circumflexbig": 'ˆ', "tildebig": '˜',
|
||||||
|
"suchthat": '∍', "carriagereturn": '↵', "aleph": 'ℵ',
|
||||||
|
"Ifraktur": 'ℑ', "Rfraktur": 'ℜ', "weierstrass": '℘',
|
||||||
|
"circlecopyrt": '©', "trademarksans": '™', "trademarkserif": '™',
|
||||||
|
"degree1": '°', "florin1": 'ƒ',
|
||||||
|
"lozenge": '◊', "spade": '♠', "club": '♣', "heart": '♥', "diamond": '♦',
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
package pdffont
|
||||||
|
|
||||||
|
import "github.com/go-pdfkit/reader"
|
||||||
|
|
||||||
|
// readSimple reads a font addressed one byte at a time: what each code is
|
||||||
|
// called, and how wide it is.
|
||||||
|
func (f *Font) readSimple() {
|
||||||
|
f.descriptor, _ = f.doc.GetDict(f.dict, "FontDescriptor")
|
||||||
|
f.symbolic = symbolicFlag(f.doc, f.descriptor)
|
||||||
|
f.names = f.readEncoding()
|
||||||
|
f.readSimpleWidths()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readType3 reads a font whose glyphs are little drawings.
|
||||||
|
func (f *Font) readType3() {
|
||||||
|
f.descriptor, _ = f.doc.GetDict(f.dict, "FontDescriptor")
|
||||||
|
f.names = f.readEncoding()
|
||||||
|
f.charProcs, _ = f.doc.GetDict(f.dict, "CharProcs")
|
||||||
|
f.t3Res, _ = f.doc.GetDict(f.dict, "Resources")
|
||||||
|
if m := floatArray(f.doc, f.dict.Get("FontMatrix")); len(m) >= 6 {
|
||||||
|
copy(f.fontMatrix[:], m[:6])
|
||||||
|
}
|
||||||
|
f.readSimpleWidths()
|
||||||
|
// A Type 3 font's widths are in its own glyph space, which the font
|
||||||
|
// matrix maps onto text space.
|
||||||
|
for code, w := range f.widths {
|
||||||
|
f.widths[code] = w * 1000 * f.fontMatrix[0]
|
||||||
|
}
|
||||||
|
f.defaultW = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbolicFlag reads whether a font says it is addressed through its own
|
||||||
|
// character map rather than through an encoding of names.
|
||||||
|
func symbolicFlag(d *reader.Document, descriptor reader.Dict) bool {
|
||||||
|
flags, ok := reader.ToInt(resolve(d, descriptor.Get("Flags")))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const symbolic, nonSymbolic = 1 << 2, 1 << 5
|
||||||
|
return flags&symbolic != 0 && flags&nonSymbolic == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEncoding works out what each code of a simple font is called: a base
|
||||||
|
// table, and then whatever differences the font names on top of it.
|
||||||
|
func (f *Font) readEncoding() map[int]string {
|
||||||
|
base := StandardEncoding
|
||||||
|
out := map[int]string{}
|
||||||
|
f.chosen = map[int]bool{}
|
||||||
|
enc := resolve(f.doc, f.dict.Get("Encoding"))
|
||||||
|
if name, ok := reader.ToName(enc); ok {
|
||||||
|
table := NamedEncoding(name, base)
|
||||||
|
fillNames(out, table)
|
||||||
|
// A named encoding is the document's own word on what its codes are,
|
||||||
|
// symbolic font or not.
|
||||||
|
for code, n := range table {
|
||||||
|
if n != "" {
|
||||||
|
f.chosen[code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
encDict, ok := reader.ToDict(enc)
|
||||||
|
if !ok {
|
||||||
|
fillNames(out, base)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if name, ok := reader.ToName(resolve(f.doc, encDict.Get("BaseEncoding"))); ok {
|
||||||
|
base = NamedEncoding(name, base)
|
||||||
|
}
|
||||||
|
fillNames(out, base)
|
||||||
|
f.applyDifferences(out, encDict)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedEncoding is the table a name stands for, or the fallback for a name
|
||||||
|
// that is not one of them.
|
||||||
|
func NamedEncoding(name reader.Name, fallback [256]string) [256]string {
|
||||||
|
switch name {
|
||||||
|
case "WinAnsiEncoding":
|
||||||
|
return WinAnsiEncoding
|
||||||
|
case "StandardEncoding", "MacRomanEncoding", "MacExpertEncoding":
|
||||||
|
// Mac Roman differs from the standard encoding above code 127, where
|
||||||
|
// almost nothing in a Latin document lives; using the standard table
|
||||||
|
// is closer than using none.
|
||||||
|
return StandardEncoding
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillNames copies a table into the map the rest of this works from.
|
||||||
|
func fillNames(out map[int]string, table [256]string) {
|
||||||
|
for code, name := range table {
|
||||||
|
if name != "" {
|
||||||
|
out[code] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDifferences reads the array that names glyphs a code at a time: a
|
||||||
|
// number, then the names of the codes from there on.
|
||||||
|
func (f *Font) applyDifferences(out map[int]string, encDict reader.Dict) {
|
||||||
|
arr, ok := reader.ToArray(resolve(f.doc, encDict.Get("Differences")))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := 0
|
||||||
|
for _, e := range arr {
|
||||||
|
v := resolve(f.doc, e)
|
||||||
|
if n, ok := reader.ToInt(v); ok {
|
||||||
|
code = int(n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := reader.ToName(v); ok {
|
||||||
|
if code >= 0 && code < 256 {
|
||||||
|
out[code] = string(name)
|
||||||
|
f.chosen[code] = true
|
||||||
|
}
|
||||||
|
code++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSimpleWidths reads /Widths, which gives one width per code from
|
||||||
|
// /FirstChar on.
|
||||||
|
func (f *Font) readSimpleWidths() {
|
||||||
|
first, ok := reader.ToInt(resolve(f.doc, f.dict.Get("FirstChar")))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arr, ok := reader.ToArray(resolve(f.doc, f.dict.Get("Widths")))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, e := range arr {
|
||||||
|
if v, ok := reader.ToFloat(resolve(f.doc, e)); ok {
|
||||||
|
f.widths[int(first)+i] = v / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mw := floatOf(resolve(f.doc, f.descriptor.Get("MissingWidth")), -1); mw >= 0 {
|
||||||
|
f.defaultW = mw / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readComposite reads a font addressed by character identifier: its descendant
|
||||||
|
// holds the widths, the descriptor and the map from identifier to glyph.
|
||||||
|
func (f *Font) readComposite() {
|
||||||
|
f.defaultW = 1
|
||||||
|
arr, ok := reader.ToArray(resolve(f.doc, f.dict.Get("DescendantFonts")))
|
||||||
|
if !ok || len(arr) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kid, ok := reader.ToDict(resolve(f.doc, arr[0]))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, ok := reader.ToFloat(resolve(f.doc, kid.Get("DW"))); ok {
|
||||||
|
f.defaultW = v / 1000
|
||||||
|
}
|
||||||
|
f.readCIDWidths(kid)
|
||||||
|
f.descriptor, _ = f.doc.GetDict(kid, "FontDescriptor")
|
||||||
|
f.symbolic = symbolicFlag(f.doc, f.descriptor)
|
||||||
|
if stream, ok := reader.ToStream(resolve(f.doc, kid.Get("CIDToGIDMap"))); ok {
|
||||||
|
if data, img, err := f.doc.DecodeStream(stream); err == nil && img == "" {
|
||||||
|
f.cidToGID = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCIDWidths reads the /W array, which names widths one identifier at a
|
||||||
|
// time or a run at a time.
|
||||||
|
func (f *Font) readCIDWidths(kid reader.Dict) {
|
||||||
|
arr, ok := reader.ToArray(resolve(f.doc, kid.Get("W")))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i < len(arr); {
|
||||||
|
first, ok := reader.ToInt(resolve(f.doc, arr[i]))
|
||||||
|
if !ok || i+1 >= len(arr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := resolve(f.doc, arr[i+1])
|
||||||
|
if list, ok := reader.ToArray(next); ok {
|
||||||
|
for k, e := range list {
|
||||||
|
if v, ok := reader.ToFloat(resolve(f.doc, e)); ok {
|
||||||
|
f.widths[int(first)+k] = v / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last, ok := reader.ToInt(next)
|
||||||
|
if !ok || i+2 >= len(arr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, ok := reader.ToFloat(resolve(f.doc, arr[i+2]))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last >= first && last-first < 1<<20 {
|
||||||
|
for c := first; c <= last; c++ {
|
||||||
|
f.widths[int(c)] = w / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// floatArray reads an array of numbers, or nothing.
|
||||||
|
func floatArray(d *reader.Document, o reader.Object) []float64 {
|
||||||
|
arr, ok := reader.ToArray(resolve(d, o))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]float64, 0, len(arr))
|
||||||
|
for _, e := range arr {
|
||||||
|
v, ok := reader.ToFloat(resolve(d, e))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
package pdffont
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"github.com/go-pdfkit/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readToUnicode reads the font's /ToUnicode map, which is the one thing in a
|
||||||
|
// PDF that says what its text actually says. Without it a page can be drawn
|
||||||
|
// but not read: a subsetted font naming its glyphs by number carries no other
|
||||||
|
// clue.
|
||||||
|
func (f *Font) readToUnicode() map[int]string {
|
||||||
|
stream, ok := reader.ToStream(resolve(f.doc, f.dict.Get("ToUnicode")))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, img, err := f.doc.DecodeStream(stream)
|
||||||
|
if err != nil || img != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ReadToUnicode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadToUnicode decodes a ToUnicode CMap: a little PostScript program whose
|
||||||
|
// only meaningful parts are the bfchar and bfrange blocks saying which
|
||||||
|
// characters each code stands for.
|
||||||
|
func ReadToUnicode(data []byte) map[int]string {
|
||||||
|
out := map[int]string{}
|
||||||
|
toks := cmapTokens(data)
|
||||||
|
for i := 0; i < len(toks); i++ {
|
||||||
|
switch toks[i].word {
|
||||||
|
case "beginbfchar":
|
||||||
|
i = readBFChar(toks, i+1, out)
|
||||||
|
case "beginbfrange":
|
||||||
|
i = readBFRange(toks, i+1, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxToUnicodeEntries is how many codes one map may name.
|
||||||
|
//
|
||||||
|
// A bfrange names a run of codes in about twenty bytes, and only the width of
|
||||||
|
// a single run was bounded — not how many runs a map could hold. So the size
|
||||||
|
// of the answer had nothing to do with the size of the question: 585 bytes
|
||||||
|
// produced 655 360 entries in 2.3 seconds, and 10 655 bytes produced
|
||||||
|
// 13 107 200 entries and a gigabyte. Every font on every page is read this
|
||||||
|
// way, so that was a gigabyte per font.
|
||||||
|
//
|
||||||
|
// The bound is not a guess. Across 5 338 /ToUnicode maps taken out of real
|
||||||
|
// documents — arXiv figures, government forms, and mozilla's pdf.js corpus —
|
||||||
|
// the median names 13 codes, the 99th percentile names 538, and the largest
|
||||||
|
// names exactly 65 536: one whole two-byte code space, which is as many codes
|
||||||
|
// as a font of that shape can have. This allows four times that, so a
|
||||||
|
// document has to be malformed or hostile to reach it, and a map that does is
|
||||||
|
// cut off there rather than being allowed to ask for everything.
|
||||||
|
const maxToUnicodeEntries = 1 << 18
|
||||||
|
|
||||||
|
// A cmapToken is one piece of such a program: a hexadecimal string, an array
|
||||||
|
// bracket, or a bare word.
|
||||||
|
type cmapToken struct {
|
||||||
|
word string
|
||||||
|
hex []byte
|
||||||
|
isHex bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmapTokens cuts the program up. Only three shapes matter — a hexadecimal
|
||||||
|
// string, a bracket, and a word — and everything else can be a word.
|
||||||
|
func cmapTokens(b []byte) []cmapToken {
|
||||||
|
var out []cmapToken
|
||||||
|
for i := 0; i < len(b); {
|
||||||
|
c := b[i]
|
||||||
|
switch {
|
||||||
|
case c == '%':
|
||||||
|
for i < len(b) && b[i] != '\n' && b[i] != '\r' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case c == '<':
|
||||||
|
j := i + 1
|
||||||
|
for j < len(b) && b[j] != '>' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, cmapToken{hex: unhexBytes(b[i+1 : min(j, len(b))]), isHex: true})
|
||||||
|
i = min(j+1, len(b))
|
||||||
|
case c == '[' || c == ']':
|
||||||
|
out = append(out, cmapToken{word: string(c)})
|
||||||
|
i++
|
||||||
|
case c <= ' ':
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
j := i
|
||||||
|
for j < len(b) && b[j] > ' ' && b[j] != '<' && b[j] != '[' && b[j] != ']' && b[j] != '%' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, cmapToken{word: string(b[i:j])})
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBFChar reads pairs of "code, what it stands for" up to the block's end.
|
||||||
|
func readBFChar(toks []cmapToken, i int, out map[int]string) int {
|
||||||
|
for i+1 < len(toks) {
|
||||||
|
if toks[i].word == "endbfchar" {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if len(out) >= maxToUnicodeEntries {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if !toks[i].isHex || !toks[i+1].isHex {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
out[codeOf(toks[i].hex)] = textOf(toks[i+1].hex)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBFRange reads runs of codes: either a first character the run counts on
|
||||||
|
// from, or a list naming each one.
|
||||||
|
func readBFRange(toks []cmapToken, i int, out map[int]string) int {
|
||||||
|
for i+2 < len(toks) {
|
||||||
|
if toks[i].word == "endbfrange" {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if !toks[i].isHex || !toks[i+1].isHex {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
lo, hi := codeOf(toks[i].hex), codeOf(toks[i+1].hex)
|
||||||
|
if hi < lo || hi-lo > 1<<16 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if len(out) >= maxToUnicodeEntries {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case toks[i+2].isHex:
|
||||||
|
base := utf16Runes(toks[i+2].hex)
|
||||||
|
for c := lo; c <= hi && len(out) < maxToUnicodeEntries; c++ {
|
||||||
|
out[c] = countOn(base, c-lo)
|
||||||
|
}
|
||||||
|
i += 3
|
||||||
|
case toks[i+2].word == "[":
|
||||||
|
j := i + 3
|
||||||
|
for c := lo; c <= hi && j < len(toks) && toks[j].word != "]"; c++ {
|
||||||
|
if toks[j].isHex && len(out) < maxToUnicodeEntries {
|
||||||
|
out[c] = textOf(toks[j].hex)
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
for j < len(toks) && toks[j].word != "]" {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
i = j + 1
|
||||||
|
default:
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeOf reads a code, which is one or two bytes written as hexadecimal.
|
||||||
|
func codeOf(b []byte) int {
|
||||||
|
v := 0
|
||||||
|
for _, c := range b {
|
||||||
|
v = v<<8 | int(c)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// textOf reads what a code stands for: characters written as sixteen-bit
|
||||||
|
// units, the way the format carries text.
|
||||||
|
func textOf(b []byte) string {
|
||||||
|
return string(utf16Runes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf16Runes decodes the sixteen-bit units a ToUnicode value is written in.
|
||||||
|
func utf16Runes(b []byte) []rune {
|
||||||
|
units := make([]uint16, 0, len(b)/2)
|
||||||
|
for i := 0; i+1 < len(b); i += 2 {
|
||||||
|
units = append(units, uint16(b[i])<<8|uint16(b[i+1]))
|
||||||
|
}
|
||||||
|
return utf16.Decode(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countOn is what the n'th code of a range stands for, counting on from the
|
||||||
|
// first: the last character moves and the rest stay, which is what the
|
||||||
|
// specification says a range means.
|
||||||
|
func countOn(base []rune, n int) string {
|
||||||
|
if len(base) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := append([]rune{}, base...)
|
||||||
|
out[len(out)-1] += rune(n)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unhexBytes reads a hexadecimal string, ignoring whatever it was laid out
|
||||||
|
// with. An odd digit at the end is taken as the high half of a byte, which is
|
||||||
|
// what the format says.
|
||||||
|
func unhexBytes(b []byte) []byte {
|
||||||
|
var digits []byte
|
||||||
|
for _, c := range b {
|
||||||
|
if isHexDigit(c) {
|
||||||
|
digits = append(digits, hexVal(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(digits)%2 == 1 {
|
||||||
|
digits = append(digits, 0)
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(digits)/2)
|
||||||
|
for i := 0; i+1 < len(digits); i += 2 {
|
||||||
|
out = append(out, digits[i]<<4|digits[i+1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexDigit(c byte) bool {
|
||||||
|
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexVal(c byte) byte {
|
||||||
|
switch {
|
||||||
|
case c <= '9':
|
||||||
|
return c - '0'
|
||||||
|
case c <= 'F':
|
||||||
|
return c - 'A' + 10
|
||||||
|
default:
|
||||||
|
return c - 'a' + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// min is the smaller of two numbers.
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimText tidies what a map gives back: the format allows a value to carry
|
||||||
|
// the replacement character or nothing at all, and neither is text.
|
||||||
|
func TrimText(s string) string {
|
||||||
|
s = strings.TrimFunc(s, func(r rune) bool { return r == 0 || r == 0xFFFD })
|
||||||
|
return s
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
cov.out
|
||||||
|
coverage.out
|
||||||
|
*.pdf
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026 the go-pdfkit/reader authors
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# reader
|
||||||
|
|
||||||
|
[](https://github.com/go-pdfkit/reader/actions/workflows/ci.yml)
|
||||||
|
[](https://pkg.go.dev/github.com/go-pdfkit/reader)
|
||||||
|
[](https://goreportcard.com/report/github.com/go-pdfkit/reader)
|
||||||
|
[](LICENSE)
|
||||||
|
[](#testing)
|
||||||
|
|
||||||
|
A pure-Go, **zero-C** PDF **reader** — the parsing half of
|
||||||
|
[go-pdfkit](https://github.com/go-pdfkit). Where
|
||||||
|
[`pdfkit`](https://github.com/go-pdfkit/pdfkit) authors new PDF 1.7, this
|
||||||
|
module takes existing PDF bytes apart: lexer, object model, cross-reference
|
||||||
|
tables and streams, stream filters, the standard security handler, the page
|
||||||
|
tree, and content streams. It also writes the same object graph back out,
|
||||||
|
which is what every operation on an existing file needs.
|
||||||
|
Nothing outside the Go standard library is required, so it builds for
|
||||||
|
`GOOS=js/wasm` and every 64-bit architecture the fleet targets.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Landing in waves. This module currently provides the **object layer** and
|
||||||
|
the **document structure**:
|
||||||
|
|
||||||
|
- **Lexer** — the full PDF object syntax: integers and reals in the shapes
|
||||||
|
producers actually write, literal strings with their escapes, hex strings,
|
||||||
|
names with `#xx` escapes, arrays, dictionaries, streams, and keywords.
|
||||||
|
- **Objects** — `Null`, `Bool`, `Integer`, `Real`, `String`, `Name`, `Array`,
|
||||||
|
`Dict`, `Stream`, and indirect `Ref`, with the `N G R` / `N G obj` lookahead
|
||||||
|
resolved the way the specification requires.
|
||||||
|
- **Filters** — `FlateDecode`, `LZWDecode` (with PDF's `EarlyChange`, which the
|
||||||
|
standard library's LZW does not implement), `ASCIIHexDecode`,
|
||||||
|
`ASCII85Decode`, `RunLengthDecode`, `Crypt`, plus the PNG and TIFF
|
||||||
|
predictors. Image filters (`DCTDecode`, `JPXDecode`, `CCITTFaxDecode`,
|
||||||
|
`JBIG2Decode`) are reported rather than applied, so an image consumer gets
|
||||||
|
the encoded bytes.
|
||||||
|
- **Filters that fail** — a chain that cannot be run to the end is not the end
|
||||||
|
of the stream. `DecodeRecovering` returns what the filters before the failure
|
||||||
|
produced — the prefix a damaged Flate stream did inflate, not its compressed
|
||||||
|
bytes — and says so through `Decoded.Recovered` and `Decoded.Cause`. Bytes no
|
||||||
|
filter decoded arrive in `Decoded.Undecoded`, never in `Decoded.Data`, so a
|
||||||
|
compressed stream cannot be painted as samples or tokenised as content by a
|
||||||
|
caller that forgot to check a flag.
|
||||||
|
`Decode` is the strict reading and refuses such a stream outright, so a
|
||||||
|
caller that must not act on damaged data says which it wants by which one it
|
||||||
|
calls. Page content takes the lenient reading, because a page whose content
|
||||||
|
will not decode is still a page; `PageContentDecoded` reports whether any
|
||||||
|
salvaging happened. Cross-reference streams take the strict one: a table that
|
||||||
|
has to be guessed at is worse than no table, and the repair below is the
|
||||||
|
answer to one.
|
||||||
|
- **Cross-references** — classic tables, cross-reference **streams**, **object
|
||||||
|
streams**, `/Prev` chains, and the `/XRefStm` of a hybrid file, newest
|
||||||
|
definition winning.
|
||||||
|
- **Repair** — a file whose tables are missing, truncated or simply wrong is
|
||||||
|
rebuilt by scanning it for object headers, trailers and, failing those, for
|
||||||
|
a catalogue; a file that kept its pages but lost its catalogue gets one.
|
||||||
|
This is not an exceptional path: it is what makes a reader usable. The
|
||||||
|
rebuild establishes the file key before it reads object streams, so a file
|
||||||
|
whose catalogue lives in an encrypted one can be rebuilt at all; it takes
|
||||||
|
`/Encrypt` from a cross-reference stream's dictionary when the file has no
|
||||||
|
trailer keyword to name it in. Where two object streams claim the same
|
||||||
|
object, the later one in the file wins, the same rule the header scan
|
||||||
|
applies to objects written directly — so the same damaged file reads the
|
||||||
|
same way twice. When a rebuild fails, the error says what the rebuild found
|
||||||
|
and not merely that the tables could not be read.
|
||||||
|
- **Documents** — `Open`, object resolution with cycle and recursion guards,
|
||||||
|
the trailer, the catalogue, and the page tree with the four attributes a
|
||||||
|
page inherits from its ancestors.
|
||||||
|
- **Encryption** — the standard security handler in every revision, both ways:
|
||||||
|
reading RC4 at 40 and 128 bits, AESV2, and the AES-256 of `/R 5` and `/R 6`,
|
||||||
|
with crypt filters, `/Identity` and `/EncryptMetadata`; and writing AES-256
|
||||||
|
or AES-128, with permissions, a user password and an owner password.
|
||||||
|
A password is tried as the user password and as the owner password, and
|
||||||
|
`Open` uses the empty one, so a file protected only against editing opens
|
||||||
|
with no password at all.
|
||||||
|
and steps over rubbish rather than losing the operations around it, with
|
||||||
|
inline images read whole. Where an inline image ends is the one genuinely
|
||||||
|
ambiguous thing in a content stream, since its data may spell EI itself;
|
||||||
|
the length is computed from the image's own geometry where it can be, and
|
||||||
|
otherwise every candidate EI is tried until the data before one actually
|
||||||
|
decodes.
|
||||||
|
- **Writing** — the exact inverse of the parser: objects rendered in PDF
|
||||||
|
syntax with stable dictionary ordering and strings in whichever of the two
|
||||||
|
forms is shorter, and a `Writer` that copies whole object graphs out of one
|
||||||
|
or more documents, renumbering as it goes, then lays down a cross-reference
|
||||||
|
table and a trailer. `NewPackedWriter` instead puts what it can into
|
||||||
|
compressed **object streams** and ends in a **cross-reference stream**,
|
||||||
|
which is what makes a file small.
|
||||||
|
Measured against a corpus of **118 863 real PDFs** — Matplotlib, cairo,
|
||||||
|
pdfTeX, Ghostscript, Adobe, R, Apache FOP, PDF 1.3 through 1.7 — `Open`
|
||||||
|
succeeds on **118 833** of them and finds 138 337 pages, in 8 seconds, with no
|
||||||
|
panics. Of the thirty it refuses, twenty-seven are PNG files with a `.pdf`
|
||||||
|
extension, one is PostScript, and two are PDFs truncated past recovery.
|
||||||
|
|
||||||
|
The one encrypted PDF the corpus holds — `/V 4 /R 4`, written by macOS
|
||||||
|
Quartz — decrypts to readable metadata and a valid content stream, which is
|
||||||
|
the only genuinely independent check of the key derivation there is here;
|
||||||
|
the other revisions are round-tripped against a test encryptor written from
|
||||||
|
the same algorithms.
|
||||||
|
|
||||||
|
The content-stream tokeniser reads **1 536 769 753 operations** across those
|
||||||
|
138 337 pages in a minute and a half, with no panics, no page failing to
|
||||||
|
decode, and no operator outside the seventy the format defines — beyond four
|
||||||
|
`arc` and six `nan` written by a producer that was simply wrong.
|
||||||
|
|
||||||
|
Rewriting is checked the same way: every one of the **118 833** files that
|
||||||
|
open is copied object by object into a new file, which is then re-read and
|
||||||
|
compared on page count, media boxes and the bytes of every content stream.
|
||||||
|
**All 118 833 match**, 35.5 GB in and 35.2 GB out, in twenty-eight seconds —
|
||||||
|
and **all 118 833 match again** when the packed writer is used instead, which
|
||||||
|
brings the output down to **94.6%** of the input.
|
||||||
|
|
||||||
|
Next wave: the operations built on all of this.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get github.com/go-pdfkit/reader
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -covermode=set ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
CI gates on **exact 100% statement coverage**, `go vet`, and a cross-compile
|
||||||
|
across `linux/{amd64,arm64,riscv64,loong64,ppc64le,s390x}`, `js/wasm`,
|
||||||
|
`darwin/arm64` and `windows/amd64`.
|
||||||
|
|
||||||
|
## The rest of the family
|
||||||
|
|
||||||
|
[**reader**](https://github.com/go-pdfkit/reader) reads and writes the format ·
|
||||||
|
[**ops**](https://github.com/go-pdfkit/ops) is the verbs and the `pdfops` command ·
|
||||||
|
[**render**](https://github.com/go-pdfkit/render) turns a page into pixels ·
|
||||||
|
[**pdffont**](https://github.com/go-pdfkit/pdffont) is what a document says about a font ·
|
||||||
|
[**extract**](https://github.com/go-pdfkit/extract) reads a page back ·
|
||||||
|
[**coedit**](https://github.com/go-pdfkit/coedit) is a PDF several people edit at once ·
|
||||||
|
[**app**](https://github.com/go-pdfkit/app) is the browser workbench.
|
||||||
|
|
||||||
|
Documentation for all of it: <https://go-pdfkit.github.io/docs/>
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BSD-3-Clause — see [LICENSE](LICENSE). Copyright the go-pdfkit/reader authors.
|
||||||
+579
@@ -0,0 +1,579 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CCITT Group 3 and Group 4 fax decoding, as used by /CCITTFaxDecode.
|
||||||
|
//
|
||||||
|
// WHY THIS IS HERE
|
||||||
|
//
|
||||||
|
// A scanned form is a fax. 67 of the 1 633 real forms in the corpus — the
|
||||||
|
// eleven issuing bodies, not the vendor test suites — carry a CCITT-encoded
|
||||||
|
// image, 263 images between them, and 6 of their pages have nothing else on
|
||||||
|
// them at all. Those six pages drew entirely blank, and a blank page is the
|
||||||
|
// failure a reader notices before any other.
|
||||||
|
//
|
||||||
|
// WHY IT BELONGS IN THE READER RATHER THAN IN A RENDERER
|
||||||
|
//
|
||||||
|
// The other filters this package stops at — DCT, JPX, JBIG2 — carry an image
|
||||||
|
// with its own idea of how many components it has and how deep they are. This
|
||||||
|
// one does not: it produces bilevel samples, one bit a pixel, rows padded to a
|
||||||
|
// byte, and the stream dictionary says what they mean. That is a byte stream,
|
||||||
|
// so it is a filter, and every caller gets it rather than each writing its own.
|
||||||
|
//
|
||||||
|
// THE REFERENCE READ BEFORE WRITING THIS
|
||||||
|
//
|
||||||
|
// ITU-T Recommendations T.4 (Group 3) and T.6 (Group 4), by way of the tables
|
||||||
|
// and the changing-element algorithm in golang.org/x/image/ccitt — read, not
|
||||||
|
// imported, because this package has no dependencies outside the standard
|
||||||
|
// library and gains none. The code tables below were extracted mechanically
|
||||||
|
// from that package's gen.go rather than retyped, because a table of 218
|
||||||
|
// variable-length codes transcribed by hand is a table with a mistake in it.
|
||||||
|
//
|
||||||
|
// The algorithm is the one T.6 Figure 1 describes. A row is decoded into one
|
||||||
|
// byte a pixel — 0xFF white, 0x00 black — and the row before it is the
|
||||||
|
// reference against which the two-dimensional modes are resolved:
|
||||||
|
//
|
||||||
|
// b1 b2
|
||||||
|
// v v
|
||||||
|
// prev: BBBBBwwwwwBBBwwwww
|
||||||
|
// curr: BBBwwwwwBBBBBBwwww
|
||||||
|
// ^ ^ ^
|
||||||
|
// a0 a1 a2
|
||||||
|
//
|
||||||
|
// a0 is where the pen is; a1 the next colour change to its right on this row;
|
||||||
|
// b1 the first change on the row above that is to the right of a0 and of the
|
||||||
|
// opposite colour to it; b2 the next change after b1. Pass mode says a1 is at
|
||||||
|
// or beyond b2; horizontal mode reads two runs and ignores the row above;
|
||||||
|
// vertical mode puts a1 at b1 plus an offset between -3 and +3.
|
||||||
|
|
||||||
|
// The two-dimensional modes of T.4 Table 1.
|
||||||
|
const (
|
||||||
|
modePass = iota
|
||||||
|
modeH
|
||||||
|
modeV0
|
||||||
|
modeVR1
|
||||||
|
modeVR2
|
||||||
|
modeVR3
|
||||||
|
modeVL1
|
||||||
|
modeVL2
|
||||||
|
modeVL3
|
||||||
|
modeExt
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ccittCode is one row of a code table: the value a code names, and the bits
|
||||||
|
// that name it, most significant first.
|
||||||
|
type ccittCode struct {
|
||||||
|
value int
|
||||||
|
bits string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittModeCodes is Table 1 of ITU-T T.4: the two-dimensional modes.
|
||||||
|
var ccittModeCodes = []ccittCode{
|
||||||
|
{modePass, "0001"}, {modeH, "001"}, {modeV0, "1"}, {modeVR1, "011"},
|
||||||
|
{modeVR2, "000011"}, {modeVR3, "0000011"}, {modeVL1, "010"}, {modeVL2, "000010"},
|
||||||
|
{modeVL3, "0000010"}, {modeExt, "0000001"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittWhiteCodes is Tables 2 and 3 of ITU-T T.4 for a white run: the
|
||||||
|
// terminating codes 0 to 63, then the make-up codes in steps of 64.
|
||||||
|
var ccittWhiteCodes = []ccittCode{
|
||||||
|
{0, "00110101"}, {1, "000111"}, {2, "0111"}, {3, "1000"},
|
||||||
|
{4, "1011"}, {5, "1100"}, {6, "1110"}, {7, "1111"},
|
||||||
|
{8, "10011"}, {9, "10100"}, {10, "00111"}, {11, "01000"},
|
||||||
|
{12, "001000"}, {13, "000011"}, {14, "110100"}, {15, "110101"},
|
||||||
|
{16, "101010"}, {17, "101011"}, {18, "0100111"}, {19, "0001100"},
|
||||||
|
{20, "0001000"}, {21, "0010111"}, {22, "0000011"}, {23, "0000100"},
|
||||||
|
{24, "0101000"}, {25, "0101011"}, {26, "0010011"}, {27, "0100100"},
|
||||||
|
{28, "0011000"}, {29, "00000010"}, {30, "00000011"}, {31, "00011010"},
|
||||||
|
{32, "00011011"}, {33, "00010010"}, {34, "00010011"}, {35, "00010100"},
|
||||||
|
{36, "00010101"}, {37, "00010110"}, {38, "00010111"}, {39, "00101000"},
|
||||||
|
{40, "00101001"}, {41, "00101010"}, {42, "00101011"}, {43, "00101100"},
|
||||||
|
{44, "00101101"}, {45, "00000100"}, {46, "00000101"}, {47, "00001010"},
|
||||||
|
{48, "00001011"}, {49, "01010010"}, {50, "01010011"}, {51, "01010100"},
|
||||||
|
{52, "01010101"}, {53, "00100100"}, {54, "00100101"}, {55, "01011000"},
|
||||||
|
{56, "01011001"}, {57, "01011010"}, {58, "01011011"}, {59, "01001010"},
|
||||||
|
{60, "01001011"}, {61, "00110010"}, {62, "00110011"}, {63, "00110100"},
|
||||||
|
{64, "11011"}, {128, "10010"}, {192, "010111"}, {256, "0110111"},
|
||||||
|
{320, "00110110"}, {384, "00110111"}, {448, "01100100"}, {512, "01100101"},
|
||||||
|
{576, "01101000"}, {640, "01100111"}, {704, "011001100"}, {768, "011001101"},
|
||||||
|
{832, "011010010"}, {896, "011010011"}, {960, "011010100"}, {1024, "011010101"},
|
||||||
|
{1088, "011010110"}, {1152, "011010111"}, {1216, "011011000"}, {1280, "011011001"},
|
||||||
|
{1344, "011011010"}, {1408, "011011011"}, {1472, "010011000"}, {1536, "010011001"},
|
||||||
|
{1600, "010011010"}, {1664, "011000"}, {1728, "010011011"}, {1792, "00000001000"},
|
||||||
|
{1856, "00000001100"}, {1920, "00000001101"}, {1984, "000000010010"}, {2048, "000000010011"},
|
||||||
|
{2112, "000000010100"}, {2176, "000000010101"}, {2240, "000000010110"}, {2304, "000000010111"},
|
||||||
|
{2368, "000000011100"}, {2432, "000000011101"}, {2496, "000000011110"}, {2560, "000000011111"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittBlackCodes is Tables 2 and 3 of ITU-T T.4 for a black run.
|
||||||
|
var ccittBlackCodes = []ccittCode{
|
||||||
|
{0, "0000110111"}, {1, "010"}, {2, "11"}, {3, "10"},
|
||||||
|
{4, "011"}, {5, "0011"}, {6, "0010"}, {7, "00011"},
|
||||||
|
{8, "000101"}, {9, "000100"}, {10, "0000100"}, {11, "0000101"},
|
||||||
|
{12, "0000111"}, {13, "00000100"}, {14, "00000111"}, {15, "000011000"},
|
||||||
|
{16, "0000010111"}, {17, "0000011000"}, {18, "0000001000"}, {19, "00001100111"},
|
||||||
|
{20, "00001101000"}, {21, "00001101100"}, {22, "00000110111"}, {23, "00000101000"},
|
||||||
|
{24, "00000010111"}, {25, "00000011000"}, {26, "000011001010"}, {27, "000011001011"},
|
||||||
|
{28, "000011001100"}, {29, "000011001101"}, {30, "000001101000"}, {31, "000001101001"},
|
||||||
|
{32, "000001101010"}, {33, "000001101011"}, {34, "000011010010"}, {35, "000011010011"},
|
||||||
|
{36, "000011010100"}, {37, "000011010101"}, {38, "000011010110"}, {39, "000011010111"},
|
||||||
|
{40, "000001101100"}, {41, "000001101101"}, {42, "000011011010"}, {43, "000011011011"},
|
||||||
|
{44, "000001010100"}, {45, "000001010101"}, {46, "000001010110"}, {47, "000001010111"},
|
||||||
|
{48, "000001100100"}, {49, "000001100101"}, {50, "000001010010"}, {51, "000001010011"},
|
||||||
|
{52, "000000100100"}, {53, "000000110111"}, {54, "000000111000"}, {55, "000000100111"},
|
||||||
|
{56, "000000101000"}, {57, "000001011000"}, {58, "000001011001"}, {59, "000000101011"},
|
||||||
|
{60, "000000101100"}, {61, "000001011010"}, {62, "000001100110"}, {63, "000001100111"},
|
||||||
|
{64, "0000001111"}, {128, "000011001000"}, {192, "000011001001"}, {256, "000001011011"},
|
||||||
|
{320, "000000110011"}, {384, "000000110100"}, {448, "000000110101"}, {512, "0000001101100"},
|
||||||
|
{576, "0000001101101"}, {640, "0000001001010"}, {704, "0000001001011"}, {768, "0000001001100"},
|
||||||
|
{832, "0000001001101"}, {896, "0000001110010"}, {960, "0000001110011"}, {1024, "0000001110100"},
|
||||||
|
{1088, "0000001110101"}, {1152, "0000001110110"}, {1216, "0000001110111"}, {1280, "0000001010010"},
|
||||||
|
{1344, "0000001010011"}, {1408, "0000001010100"}, {1472, "0000001010101"}, {1536, "0000001011010"},
|
||||||
|
{1600, "0000001011011"}, {1664, "0000001100100"}, {1728, "0000001100101"}, {1792, "00000001000"},
|
||||||
|
{1856, "00000001100"}, {1920, "00000001101"}, {1984, "000000010010"}, {2048, "000000010011"},
|
||||||
|
{2112, "000000010100"}, {2176, "000000010101"}, {2240, "000000010110"}, {2304, "000000010111"},
|
||||||
|
{2368, "000000011100"}, {2432, "000000011101"}, {2496, "000000011110"}, {2560, "000000011111"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ccittTable decodes one of the code tables. Codes are grouped by their
|
||||||
|
// length, which is the whole of the decoding: read a bit, lengthen the code by
|
||||||
|
// one, and ask whether a code of that length says anything. A prefix-free code
|
||||||
|
// cannot answer twice, so the first answer is the only one.
|
||||||
|
type ccittTable struct {
|
||||||
|
byLength []map[uint32]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCCITTTable groups a table's codes by length. The bits are read from the
|
||||||
|
// strings the tables are written in, so what the code says and what the
|
||||||
|
// specification says are the same text.
|
||||||
|
func newCCITTTable(codes []ccittCode) *ccittTable {
|
||||||
|
longest := 0
|
||||||
|
for _, c := range codes {
|
||||||
|
if len(c.bits) > longest {
|
||||||
|
longest = len(c.bits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := &ccittTable{byLength: make([]map[uint32]int, longest+1)}
|
||||||
|
for _, c := range codes {
|
||||||
|
var v uint32
|
||||||
|
for _, b := range c.bits {
|
||||||
|
v <<= 1
|
||||||
|
if b == '1' {
|
||||||
|
v |= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := len(c.bits)
|
||||||
|
if t.byLength[n] == nil {
|
||||||
|
t.byLength[n] = map[uint32]int{}
|
||||||
|
}
|
||||||
|
t.byLength[n][v] = c.value
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ccittModes = newCCITTTable(ccittModeCodes)
|
||||||
|
ccittWhite = newCCITTTable(ccittWhiteCodes)
|
||||||
|
ccittBlack = newCCITTTable(ccittBlackCodes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ccittBits reads one bit at a time, most significant first, which is the
|
||||||
|
// order a fax is written in.
|
||||||
|
type ccittBits struct {
|
||||||
|
data []byte
|
||||||
|
pos int // in bits
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ccittBits) next() (uint32, bool) {
|
||||||
|
if b.pos >= 8*len(b.data) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
bit := (b.data[b.pos/8] >> (7 - uint(b.pos%8))) & 1
|
||||||
|
b.pos++
|
||||||
|
return uint32(bit), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// align moves to the next byte boundary, which /EncodedByteAlign asks for at
|
||||||
|
// the start of every row.
|
||||||
|
func (b *ccittBits) align() { b.pos = (b.pos + 7) &^ 7 }
|
||||||
|
|
||||||
|
func (b *ccittBits) exhausted() bool { return b.pos >= 8*len(b.data) }
|
||||||
|
|
||||||
|
// restIsFill says whether nothing but zero bits remain. A fax is padded to a
|
||||||
|
// byte, and often to rather more than a byte, and zeros decode as perfectly
|
||||||
|
// good two-dimensional modes: pass mode is 0001 and vertical-left-3 is 0000010,
|
||||||
|
// so a decoder that does not stop here goes on inventing rows that look like
|
||||||
|
// the last real one. One French form gave 1 636 rows for a 208-row image that
|
||||||
|
// way, and every one of the surplus rows had plausible ink on it.
|
||||||
|
func (b *ccittBits) restIsFill() bool {
|
||||||
|
for i := b.pos; i < 8*len(b.data); i++ {
|
||||||
|
if b.data[i/8]&(1<<(7-uint(i%8))) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// read decodes one code. It returns false at the end of the data or on a code
|
||||||
|
// the table does not name — the caller decides which of those is fatal, since a
|
||||||
|
// truncated fax is common and worth showing as far as it goes.
|
||||||
|
func (t *ccittTable) read(b *ccittBits) (int, bool) {
|
||||||
|
var code uint32
|
||||||
|
for n := 1; n < len(t.byLength); n++ {
|
||||||
|
bit, ok := b.next()
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
code = code<<1 | bit
|
||||||
|
if v, ok := t.byLength[n][code]; ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipEOL consumes an end-of-line code, 000000000001, if one is next, and any
|
||||||
|
// fill bits before it. A Group 3 fax may have one at the end of every row, may
|
||||||
|
// have none at all, and may have several together at the end; all three are
|
||||||
|
// found in the corpus, so none of them may be an error.
|
||||||
|
func (b *ccittBits) skipEOL() bool {
|
||||||
|
start := b.pos
|
||||||
|
zeros := 0
|
||||||
|
for {
|
||||||
|
bit, ok := b.next()
|
||||||
|
if !ok {
|
||||||
|
b.pos = start
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if bit == 0 {
|
||||||
|
zeros++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if zeros >= 11 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
b.pos = start
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittParams are the decode parameters of /CCITTFaxDecode, with the defaults
|
||||||
|
// the specification gives.
|
||||||
|
type ccittParams struct {
|
||||||
|
k int // <0 Group 4, 0 Group 3 one-dimensional, >0 Group 3 mixed
|
||||||
|
columns int // default 1728
|
||||||
|
rows int // 0 means as many as the data holds
|
||||||
|
blackIs1 bool // false: a 0 bit is black
|
||||||
|
encodedByteAlign bool
|
||||||
|
endOfBlock bool // default true: the data ends with a block marker
|
||||||
|
}
|
||||||
|
|
||||||
|
func ccittParamsOf(parm Dict, r Resolver) ccittParams {
|
||||||
|
return ccittParams{
|
||||||
|
k: intParm(parm, "K", 0, r),
|
||||||
|
columns: intParm(parm, "Columns", 1728, r),
|
||||||
|
rows: intParm(parm, "Rows", 0, r),
|
||||||
|
blackIs1: boolParm(parm, "BlackIs1", false, r),
|
||||||
|
encodedByteAlign: boolParm(parm, "EncodedByteAlign", false, r),
|
||||||
|
endOfBlock: boolParm(parm, "EndOfBlock", true, r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endOfBlockEOLs is how many end-of-line codes in a row mean the data has
|
||||||
|
// ended: two for Group 4, which has none between its rows, and six for Group 3,
|
||||||
|
// where every row may legitimately be followed by one.
|
||||||
|
func endOfBlockEOLs(k int) int {
|
||||||
|
if k < 0 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxCCITTPixels bounds what one image may decode to, the way maxDecodedSize
|
||||||
|
// bounds a Flate stream. A fax names its own width and height in the decode
|
||||||
|
// parameters, so a file can ask for an image of any size it likes; 2^28 pixels
|
||||||
|
// is more than four times A4 at 600 dots to the inch.
|
||||||
|
var maxCCITTPixels = 1 << 28
|
||||||
|
|
||||||
|
// ccittDecode turns a fax into bilevel samples: one bit a pixel, each row
|
||||||
|
// padded to a byte boundary, which is what every other image filter in this
|
||||||
|
// package produces and what a stream dictionary's /Width, /Height and
|
||||||
|
// /BitsPerComponent describe.
|
||||||
|
func ccittDecode(data []byte, p ccittParams) ([]byte, error) {
|
||||||
|
if p.columns <= 0 {
|
||||||
|
return nil, fmt.Errorf("reader: /CCITTFaxDecode names %d columns", p.columns)
|
||||||
|
}
|
||||||
|
if p.rows < 0 {
|
||||||
|
return nil, fmt.Errorf("reader: /CCITTFaxDecode names %d rows", p.rows)
|
||||||
|
}
|
||||||
|
if p.rows > 0 && p.columns > maxCCITTPixels/p.rows {
|
||||||
|
return nil, fmt.Errorf("reader: /CCITTFaxDecode names %d by %d pixels, "+
|
||||||
|
"past the limit of %d", p.columns, p.rows, maxCCITTPixels)
|
||||||
|
}
|
||||||
|
b := &ccittBits{data: data}
|
||||||
|
stride := (p.columns + 7) / 8
|
||||||
|
curr := make([]byte, p.columns)
|
||||||
|
prev := []byte(nil)
|
||||||
|
out := []byte(nil)
|
||||||
|
for row := 0; p.rows == 0 || row < p.rows; row++ {
|
||||||
|
if p.rows == 0 && len(out)/stride*p.columns >= maxCCITTPixels {
|
||||||
|
return nil, fmt.Errorf("reader: /CCITTFaxDecode ran past %d pixels "+
|
||||||
|
"without naming how many rows it has", maxCCITTPixels)
|
||||||
|
}
|
||||||
|
if p.encodedByteAlign {
|
||||||
|
b.align()
|
||||||
|
}
|
||||||
|
// A Group 3 row may be introduced by an end-of-line code. Consuming it
|
||||||
|
// here rather than after the row means a file that has them and a file
|
||||||
|
// that does not are read the same way — and counting them is how the
|
||||||
|
// end of the data announces itself, since the block marker is nothing
|
||||||
|
// but end-of-line codes in a row.
|
||||||
|
eols := 0
|
||||||
|
for b.skipEOL() {
|
||||||
|
eols++
|
||||||
|
}
|
||||||
|
if p.endOfBlock && eols >= endOfBlockEOLs(p.k) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b.exhausted() || b.restIsFill() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
twoDimensional := p.k < 0
|
||||||
|
if p.k > 0 {
|
||||||
|
// In mixed mode a bit after the end-of-line says which the row is:
|
||||||
|
// 1 for one-dimensional, 0 for two.
|
||||||
|
// The data was checked for bits a moment ago, so this cannot
|
||||||
|
// fail; the value is taken rather than the error handled, because
|
||||||
|
// a branch that cannot be reached cannot be tested.
|
||||||
|
bit, _ := b.next()
|
||||||
|
twoDimensional = bit == 0
|
||||||
|
}
|
||||||
|
if err := ccittRow(b, curr, prev, twoDimensional); err != nil {
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A truncated fax is worth showing as far as it got: the rows
|
||||||
|
// already decoded are real, and refusing them turns a damaged
|
||||||
|
// scan into a blank page. Leaving the loop rather than returning
|
||||||
|
// here matters — the padding below is what makes the answer as
|
||||||
|
// long as /Rows promised, and a fuzz case of two bytes found that
|
||||||
|
// returning early handed the caller one row where it had asked
|
||||||
|
// for four, with no error to say so.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, ccittPack(curr, stride, p.blackIs1)...)
|
||||||
|
if prev == nil {
|
||||||
|
prev = make([]byte, p.columns)
|
||||||
|
}
|
||||||
|
copy(prev, curr)
|
||||||
|
}
|
||||||
|
// A file that says how many rows it has gets that many, padded with white
|
||||||
|
// if the data ran out, so the samples match the /Height the dictionary
|
||||||
|
// promises rather than being short by a row.
|
||||||
|
if p.rows > 0 {
|
||||||
|
white := make([]byte, stride)
|
||||||
|
if !p.blackIs1 {
|
||||||
|
for i := range white {
|
||||||
|
white[i] = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(out) < p.rows*stride {
|
||||||
|
out = append(out, white...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittPack turns a row of one byte a pixel into one bit a pixel. With
|
||||||
|
// /BlackIs1 false — the default — a 0 bit is black, so white becomes a 1.
|
||||||
|
func ccittPack(row []byte, stride int, blackIs1 bool) []byte {
|
||||||
|
out := make([]byte, stride)
|
||||||
|
for x, v := range row {
|
||||||
|
white := v != 0
|
||||||
|
if white != blackIs1 {
|
||||||
|
out[x/8] |= 1 << (7 - uint(x%8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittRow decodes one row into curr, one byte a pixel: 0xFF white, 0x00 black.
|
||||||
|
func ccittRow(b *ccittBits, curr, prev []byte, twoDimensional bool) error {
|
||||||
|
for i := range curr {
|
||||||
|
curr[i] = 0
|
||||||
|
}
|
||||||
|
at, white, first := 0, true, true
|
||||||
|
for at < len(curr) {
|
||||||
|
if !twoDimensional {
|
||||||
|
n, err := ccittRun(b, white)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if at+n > len(curr) {
|
||||||
|
return fmt.Errorf("reader: a fax row of %d pixels named %d",
|
||||||
|
len(curr), at+n)
|
||||||
|
}
|
||||||
|
fill(curr[at:at+n], white)
|
||||||
|
at += n
|
||||||
|
white = !white
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mode, ok := ccittModes.read(b)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("reader: a fax row named no mode this reader knows")
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case modePass:
|
||||||
|
// ccittFindB starts where the pen is and only walks forward,
|
||||||
|
// stopping at the end of the row, so b2 is always a pixel of this
|
||||||
|
// row at or after the pen. There is nothing to check.
|
||||||
|
b2 := ccittFindB(prev, curr, at, white, first, true)
|
||||||
|
fill(curr[at:b2], white)
|
||||||
|
at = b2
|
||||||
|
case modeH:
|
||||||
|
// Two runs, of the current colour and then the other, neither of
|
||||||
|
// them looking at the row above.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
n, err := ccittRun(b, white)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if at+n > len(curr) {
|
||||||
|
return fmt.Errorf("reader: a fax row of %d pixels named %d",
|
||||||
|
len(curr), at+n)
|
||||||
|
}
|
||||||
|
fill(curr[at:at+n], white)
|
||||||
|
at += n
|
||||||
|
white = !white
|
||||||
|
}
|
||||||
|
// Horizontal mode reads a pair, so the two flips above have put
|
||||||
|
// the colour back where it started; there is nothing to undo.
|
||||||
|
case modeV0, modeVR1, modeVR2, modeVR3, modeVL1, modeVL2, modeVL3:
|
||||||
|
a1 := ccittFindB(prev, curr, at, white, first, false) + ccittOffset(mode)
|
||||||
|
if a1 < at || a1 > len(curr) {
|
||||||
|
return fmt.Errorf("reader: a fax vertical mode named pixel %d of %d",
|
||||||
|
a1, len(curr))
|
||||||
|
}
|
||||||
|
fill(curr[at:a1], white)
|
||||||
|
at = a1
|
||||||
|
white = !white
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("reader: a fax named an extension mode, which " +
|
||||||
|
"this reader does not decode")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittOffset is how far a vertical mode moves a1 from b1.
|
||||||
|
func ccittOffset(mode int) int {
|
||||||
|
switch mode {
|
||||||
|
case modeVR1:
|
||||||
|
return 1
|
||||||
|
case modeVR2:
|
||||||
|
return 2
|
||||||
|
case modeVR3:
|
||||||
|
return 3
|
||||||
|
case modeVL1:
|
||||||
|
return -1
|
||||||
|
case modeVL2:
|
||||||
|
return -2
|
||||||
|
case modeVL3:
|
||||||
|
return -3
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittRun reads one run length, which is a sequence of make-up codes ending in
|
||||||
|
// a terminating code below 64.
|
||||||
|
func ccittRun(b *ccittBits, white bool) (int, error) {
|
||||||
|
table := ccittBlack
|
||||||
|
if white {
|
||||||
|
table = ccittWhite
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for {
|
||||||
|
n, ok := table.read(b)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("reader: a fax run named no length this reader knows")
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
if total > maxCCITTPixels {
|
||||||
|
return 0, fmt.Errorf("reader: a fax named a run of %d pixels", total)
|
||||||
|
}
|
||||||
|
if n <= 63 {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccittFindB finds b1, or b2 when second is set: the changing elements on the
|
||||||
|
// row above, as T.6 Figure 1 defines them.
|
||||||
|
func ccittFindB(prev, curr []byte, at int, white, first, second bool) int {
|
||||||
|
// The row above the first row is implicitly all white, so it has no
|
||||||
|
// changing elements and both b values are at the end of the row.
|
||||||
|
if prev == nil {
|
||||||
|
return len(curr)
|
||||||
|
}
|
||||||
|
i := at
|
||||||
|
if first {
|
||||||
|
// a0 is implicitly one pixel before the row, on white. b1 is the first
|
||||||
|
// black pixel above; b2 the first white pixel after that.
|
||||||
|
for i < len(prev) && prev[i] != 0 {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if second {
|
||||||
|
for i < len(prev) && prev[i] == 0 {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
// Walk past the run above that is of the opposite colour to the pen, then
|
||||||
|
// past the run of the pen's own colour: what follows is b1.
|
||||||
|
opposite := byte(0xFF)
|
||||||
|
if white {
|
||||||
|
opposite = 0
|
||||||
|
}
|
||||||
|
for i < len(prev) && prev[i] == opposite {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
same := ^opposite
|
||||||
|
for i < len(prev) && prev[i] == same {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if second {
|
||||||
|
for i < len(prev) && prev[i] == opposite {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolParm reads a boolean decode parameter, falling back to its default.
|
||||||
|
func boolParm(parm Dict, key Name, def bool, r Resolver) bool {
|
||||||
|
if parm == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
v, err := Resolve(parm.Get(key), r)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if b, ok := v.(Bool); ok {
|
||||||
|
return bool(b)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill paints a run of pixels, one byte each.
|
||||||
|
func fill(row []byte, white bool) {
|
||||||
|
v := byte(0)
|
||||||
|
if white {
|
||||||
|
v = 0xFF
|
||||||
|
}
|
||||||
|
for i := range row {
|
||||||
|
row[i] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
+339
@@ -0,0 +1,339 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An Operation is one step of a content stream: an operator and the operands
|
||||||
|
// that precede it. An inline image is reported as the operator "BI" with its
|
||||||
|
// dictionary and data attached, since the three keywords it spans are one
|
||||||
|
// thing.
|
||||||
|
type Operation struct {
|
||||||
|
Operator string
|
||||||
|
Operands []Object
|
||||||
|
Image *InlineImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// An InlineImage is the dictionary and the still-encoded bytes of a BI … ID …
|
||||||
|
// EI sequence. Its dictionary uses the abbreviated keys inline images are
|
||||||
|
// written with; [InlineImage.Expanded] gives the ordinary spelling.
|
||||||
|
type InlineImage struct {
|
||||||
|
Dict Dict
|
||||||
|
Raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineKeys maps the abbreviations an inline image dictionary uses to the
|
||||||
|
// names their long form would have.
|
||||||
|
var inlineKeys = map[Name]Name{
|
||||||
|
"BPC": "BitsPerComponent",
|
||||||
|
"CS": "ColorSpace",
|
||||||
|
"D": "Decode",
|
||||||
|
"DP": "DecodeParms",
|
||||||
|
"F": "Filter",
|
||||||
|
"H": "Height",
|
||||||
|
"IM": "ImageMask",
|
||||||
|
"I": "Interpolate",
|
||||||
|
"W": "Width",
|
||||||
|
"L": "Length",
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineColourSpaces maps the abbreviated colour space names.
|
||||||
|
var inlineColourSpaces = map[Name]Name{
|
||||||
|
"G": "DeviceGray",
|
||||||
|
"RGB": "DeviceRGB",
|
||||||
|
"CMYK": "DeviceCMYK",
|
||||||
|
"I": "Indexed",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded returns the image's dictionary with the abbreviated keys and colour
|
||||||
|
// space names written out, so it can be read like any image XObject.
|
||||||
|
//
|
||||||
|
// A dictionary may say the same thing twice and disagree with itself: /W 20
|
||||||
|
// beside /Width 10, /CS /RGB beside /ColorSpace /3chanRGB. The specification
|
||||||
|
// permits both spellings in an inline image and says nothing about which wins,
|
||||||
|
// and the public implementations disagree — pdf.js asks for the abbreviation
|
||||||
|
// and falls back to the written-out name, MuPDF asks the other way round.
|
||||||
|
//
|
||||||
|
// The abbreviation wins here. Two reasons: it is the spelling Table 93 gives
|
||||||
|
// for an inline image, the written-out name being the tolerated alias; and
|
||||||
|
// safedocs' own fixture for this case marks the abbreviation as the line to
|
||||||
|
// remove "to see the effect", which is a statement about what the reference
|
||||||
|
// viewer does.
|
||||||
|
//
|
||||||
|
// What is not defensible is the answer changing between runs. Expanding in one
|
||||||
|
// pass over the map made the winner depend on Go's map iteration order, which
|
||||||
|
// is deliberately random: the same file drew differently each time it was
|
||||||
|
// opened, with no way for a caller to notice. Two passes settle it — the
|
||||||
|
// written-out names first, then the abbreviations over them — and no order
|
||||||
|
// within either pass can matter, because the names in each are distinct.
|
||||||
|
func (im *InlineImage) Expanded() Dict {
|
||||||
|
out := Dict{}
|
||||||
|
for k, v := range im.Dict {
|
||||||
|
if _, abbreviated := inlineKeys[k]; abbreviated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = expandColourSpace(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range im.Dict {
|
||||||
|
long, abbreviated := inlineKeys[k]
|
||||||
|
if !abbreviated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[long] = expandColourSpace(long, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandColourSpace writes out an abbreviated colour space name, which is the
|
||||||
|
// one value that is abbreviated as well as its key.
|
||||||
|
func expandColourSpace(k Name, v Object) Object {
|
||||||
|
if k != "ColorSpace" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
n, ok := ToName(v)
|
||||||
|
if !ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if long, ok := inlineColourSpaces[n]; ok {
|
||||||
|
return long
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ContentScanner walks a content stream one operation at a time. A stream
|
||||||
|
// with rubbish in it yields the operations around the rubbish rather than
|
||||||
|
// nothing at all, which is what a renderer needs; [ContentScanner.Err] reports
|
||||||
|
// the first thing that did not parse.
|
||||||
|
type ContentScanner struct {
|
||||||
|
lex lexer
|
||||||
|
pending []Object
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContentScanner reads operations from a decoded content stream.
|
||||||
|
func NewContentScanner(data []byte) *ContentScanner {
|
||||||
|
return &ContentScanner{lex: lexer{buf: data}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err reports the first malformed token the scan stepped over, if any.
|
||||||
|
func (s *ContentScanner) Err() error { return s.err }
|
||||||
|
|
||||||
|
// Next returns the next operation, and false once the stream is exhausted.
|
||||||
|
func (s *ContentScanner) Next() (Operation, bool) {
|
||||||
|
for {
|
||||||
|
t, err := s.lex.next()
|
||||||
|
if err != nil {
|
||||||
|
s.note(err)
|
||||||
|
// Step over the byte that would not parse and carry on.
|
||||||
|
if s.lex.pos < len(s.lex.buf) {
|
||||||
|
s.lex.pos++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return Operation{}, false
|
||||||
|
}
|
||||||
|
if t.kind == tokEOF {
|
||||||
|
return Operation{}, false
|
||||||
|
}
|
||||||
|
if t.kind != tokKeyword {
|
||||||
|
s.operand(t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch string(t.text) {
|
||||||
|
case "true":
|
||||||
|
s.pending = append(s.pending, Bool(true))
|
||||||
|
continue
|
||||||
|
case "false":
|
||||||
|
s.pending = append(s.pending, Bool(false))
|
||||||
|
continue
|
||||||
|
case "null":
|
||||||
|
s.pending = append(s.pending, Null{})
|
||||||
|
continue
|
||||||
|
case "BI":
|
||||||
|
img, err := s.inlineImage()
|
||||||
|
if err != nil {
|
||||||
|
s.note(err)
|
||||||
|
return Operation{}, false
|
||||||
|
}
|
||||||
|
return s.emit("BI", img), true
|
||||||
|
}
|
||||||
|
return s.emit(string(t.text), nil), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operand parses one operand, an already-read token starting it.
|
||||||
|
func (s *ContentScanner) operand(t token) {
|
||||||
|
p := &parser{lex: s.lex}
|
||||||
|
o, err := p.parseFrom(t)
|
||||||
|
s.lex = p.lex
|
||||||
|
if err != nil {
|
||||||
|
s.note(err)
|
||||||
|
if s.lex.pos < len(s.lex.buf) {
|
||||||
|
s.lex.pos++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pending = append(s.pending, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit finishes an operation and clears the operands waiting for it.
|
||||||
|
func (s *ContentScanner) emit(op string, img *InlineImage) Operation {
|
||||||
|
out := Operation{Operator: op, Operands: s.pending, Image: img}
|
||||||
|
s.pending = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// note keeps the first error the scan met.
|
||||||
|
func (s *ContentScanner) note(err error) {
|
||||||
|
if s.err == nil {
|
||||||
|
s.err = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineImage reads the dictionary after BI and the data after ID.
|
||||||
|
func (s *ContentScanner) inlineImage() (*InlineImage, error) {
|
||||||
|
d := Dict{}
|
||||||
|
for {
|
||||||
|
t, err := s.lex.next()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t.kind == tokEOF {
|
||||||
|
return nil, &SyntaxError{t.pos, "an inline image has no ID"}
|
||||||
|
}
|
||||||
|
if t.kind == tokKeyword && string(t.text) == "ID" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if t.kind != tokName {
|
||||||
|
return nil, &SyntaxError{t.pos, "an inline image key is not a name"}
|
||||||
|
}
|
||||||
|
p := &parser{lex: s.lex}
|
||||||
|
v, err := p.parseObject()
|
||||||
|
s.lex = p.lex
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d[Name(t.text)] = v
|
||||||
|
}
|
||||||
|
// Exactly one white-space byte separates ID from the data.
|
||||||
|
if s.lex.pos < len(s.lex.buf) && isSpace(s.lex.buf[s.lex.pos]) {
|
||||||
|
s.lex.pos++
|
||||||
|
}
|
||||||
|
start := s.lex.pos
|
||||||
|
end, err := inlineImageEnd(s.lex.buf, start, d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := s.lex.buf[start:end]
|
||||||
|
s.lex.pos = end
|
||||||
|
// Step over the EI that follows.
|
||||||
|
for s.lex.pos < len(s.lex.buf) && isSpace(s.lex.buf[s.lex.pos]) {
|
||||||
|
s.lex.pos++
|
||||||
|
}
|
||||||
|
if bytes.HasPrefix(s.lex.buf[s.lex.pos:], []byte("EI")) {
|
||||||
|
s.lex.pos += 2
|
||||||
|
}
|
||||||
|
return &InlineImage{Dict: d, Raw: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operations tokenises a whole content stream. The error it returns describes
|
||||||
|
// what did not parse; the operations it returns are still worth having.
|
||||||
|
func Operations(data []byte) ([]Operation, error) {
|
||||||
|
s := NewContentScanner(data)
|
||||||
|
var out []Operation
|
||||||
|
for {
|
||||||
|
op, ok := s.Next()
|
||||||
|
if !ok {
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
out = append(out, op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageContent returns the decoded content stream of the i'th page, counting
|
||||||
|
// from one. A page whose /Contents is an array has its streams joined, as the
|
||||||
|
// specification requires, with a newline between them.
|
||||||
|
//
|
||||||
|
// A content stream whose filters cannot be applied contributes what could be
|
||||||
|
// salvaged from it rather than failing the page, which is what a renderer
|
||||||
|
// needs; the error is for a page that is not there at all.
|
||||||
|
// [Document.PageContentDecoded] says whether any salvaging happened.
|
||||||
|
func (d *Document) PageContent(i int) ([]byte, error) {
|
||||||
|
dec, err := d.PageContentDecoded(i)
|
||||||
|
return dec.Data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageContentDecoded is [Document.PageContent] with the outcome of the decode
|
||||||
|
// attached: [Decoded.Recovered] reports that at least one of the page's content
|
||||||
|
// streams could not be decoded cleanly, and [Decoded.Cause] says why.
|
||||||
|
func (d *Document) PageContentDecoded(i int) (Decoded, error) {
|
||||||
|
page, err := d.Page(i)
|
||||||
|
if err != nil {
|
||||||
|
return Decoded{}, err
|
||||||
|
}
|
||||||
|
return d.contentOf(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentOf decodes a page's /Contents, whether one stream or several.
|
||||||
|
func (d *Document) contentOf(page Dict) (Decoded, error) {
|
||||||
|
o, err := d.Resolve(page.Get("Contents"))
|
||||||
|
if err != nil {
|
||||||
|
return Decoded{}, err
|
||||||
|
}
|
||||||
|
switch v := o.(type) {
|
||||||
|
case *Stream:
|
||||||
|
return d.decodedContent(v), nil
|
||||||
|
case Array:
|
||||||
|
var out Decoded
|
||||||
|
for _, e := range v {
|
||||||
|
eo, err := d.Resolve(e)
|
||||||
|
if err != nil {
|
||||||
|
return Decoded{}, err
|
||||||
|
}
|
||||||
|
s, ok := ToStream(eo)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
part := d.decodedContent(s)
|
||||||
|
if part.Recovered && !out.Recovered {
|
||||||
|
out.Recovered, out.Cause, out.Filter = true, part.Cause, part.Filter
|
||||||
|
out.Undecoded = part.Undecoded
|
||||||
|
}
|
||||||
|
if len(part.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out.Data) > 0 {
|
||||||
|
out.Data = append(out.Data, '\n')
|
||||||
|
}
|
||||||
|
out.Data = append(out.Data, part.Data...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return Decoded{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodedContent decodes one content stream. An image filter has no business
|
||||||
|
// being there, so the bytes it holds are not content: they are reported as
|
||||||
|
// salvage, not handed over as if they could be tokenised.
|
||||||
|
func (d *Document) decodedContent(s *Stream) Decoded {
|
||||||
|
dec := d.DecodeStreamRecovering(s)
|
||||||
|
if dec.Image != "" {
|
||||||
|
return Decoded{
|
||||||
|
Undecoded: dec.Data,
|
||||||
|
Recovered: true,
|
||||||
|
Filter: dec.Image,
|
||||||
|
Cause: fmt.Errorf("reader: a content stream is filtered as an image (/%s)", dec.Image),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dec
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageOperations tokenises the i'th page's content stream, counting from one.
|
||||||
|
func (d *Document) PageOperations(i int) ([]Operation, error) {
|
||||||
|
data, err := d.PageContent(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops, _ := Operations(data)
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user