From cfde35397a52f26d6ca9f2d129b3d91cd857c981 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 25 Sep 2026 20:47:38 -0700 Subject: [PATCH] =?UTF-8?q?P5a=20plan:=20PDF=20documents=20=E2=80=94=20sev?= =?UTF-8?q?en=20Ornith=20tasks=20with=20acceptance=20tests=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + AGENTS.md | 98 + Makefile | 9 +- docs/P5a.md | 26 +- docs/implementer-log.md | 6 + docs/plans/p5a/01-kind-fetch.md | 100 + docs/plans/p5a/02-pdftext.md | 152 ++ docs/plans/p5a/03-dcache.md | 82 + docs/plans/p5a/04-pdfpool.md | 81 + docs/plans/p5a/05-pdfdoc.md | 115 ++ docs/plans/p5a/06-api.md | 150 ++ docs/plans/p5a/07-digest-cli-docs.md | 108 + docs/plans/p5a/README.md | 46 + .../p5a/_files/cmd/sift/fetchpdf_test.go | 44 + .../plans/p5a/_files/internal/api/pdf_test.go | 402 ++++ .../p5a/_files/internal/clean/kind_test.go | 55 + .../p5a/_files/internal/dcache/pdf_test.go | 128 ++ .../p5a/_files/internal/digest/pages_test.go | 40 + .../p5a/_files/internal/fetch/pdf_test.go | 178 ++ .../p5a/_files/internal/metrics/pdf_test.go | 49 + .../p5a/_files/internal/pdfdoc/pdfdoc_test.go | 249 +++ .../_files/internal/pdfpool/pdfpool_test.go | 226 +++ .../p5a/_files/internal/pdftest/pdftest.go | 133 ++ .../_files/internal/pdftext/pdftext_test.go | 252 +++ .../p5a/_files/tests/testdata/pdf/README.md | 14 + .../tests/testdata/pdf/encrypted-owner.pdf | Bin 0 -> 3408 bytes .../tests/testdata/pdf/encrypted-user.pdf | 81 + go.mod | 6 +- go.sum | 10 + internal/api/api.go | 4 +- internal/api/api_test.go | 7 +- internal/clean/clean.go | 2 +- internal/guard/guard_test.go | 48 +- internal/guard/iana.go | 5 +- tools.go | 10 + tools/run-plan.sh | 71 + .../go-opentype/opentype/.gitignore | 2 + .../github.com/go-opentype/opentype/LICENSE | 29 + .../github.com/go-opentype/opentype/README.md | 204 ++ .../go-opentype/opentype/advance.go | 43 + .../github.com/go-opentype/opentype/avar.go | 77 + .../go-opentype/opentype/barecff.go | 202 ++ vendor/github.com/go-opentype/opentype/cff.go | 1202 +++++++++++ .../github.com/go-opentype/opentype/cff2.go | 724 +++++++ .../go-opentype/opentype/cffhint.go | 649 ++++++ .../go-opentype/opentype/cffstrings.go | 88 + .../go-opentype/opentype/charmap.go | 75 + .../github.com/go-opentype/opentype/cmap.go | 933 +++++++++ .../go-opentype/opentype/descriptor.go | 163 ++ vendor/github.com/go-opentype/opentype/doc.go | 51 + .../github.com/go-opentype/opentype/face.go | 358 ++++ .../github.com/go-opentype/opentype/font.go | 345 ++++ .../github.com/go-opentype/opentype/fvar.go | 213 ++ .../github.com/go-opentype/opentype/gdef.go | 263 +++ .../github.com/go-opentype/opentype/glyf.go | 291 +++ .../go-opentype/opentype/glyphpath.go | 164 ++ .../github.com/go-opentype/opentype/gpos.go | 1653 ++++++++++++++++ .../github.com/go-opentype/opentype/gsub.go | 1197 +++++++++++ .../github.com/go-opentype/opentype/gvar.go | 504 +++++ .../github.com/go-opentype/opentype/hint.go | 1755 +++++++++++++++++ .../go-opentype/opentype/instance.go | 295 +++ .../github.com/go-opentype/opentype/kern.go | 81 + .../github.com/go-opentype/opentype/layout.go | 376 ++++ .../github.com/go-opentype/opentype/math.go | 674 +++++++ .../go-opentype/opentype/metricvar.go | 465 +++++ .../go-opentype/opentype/positional.go | 185 ++ .../github.com/go-opentype/opentype/raster.go | 171 ++ .../go-opentype/opentype/rawtable.go | 38 + .../github.com/go-opentype/opentype/reader.go | 77 + .../go-opentype/opentype/sfntbuild.go | 125 ++ .../github.com/go-opentype/opentype/shape.go | 161 ++ .../github.com/go-opentype/opentype/subset.go | 224 +++ .../go-opentype/opentype/subsetcff.go | 406 ++++ .../github.com/go-opentype/opentype/tables.go | 122 ++ .../github.com/go-opentype/opentype/type1.go | 467 +++++ .../go-opentype/opentype/type1exec.go | 493 +++++ .../go-opentype/opentype/vertical.go | 193 ++ .../github.com/go-pdfkit/extract/.gitignore | 1 + vendor/github.com/go-pdfkit/extract/LICENSE | 29 + vendor/github.com/go-pdfkit/extract/README.md | 91 + .../github.com/go-pdfkit/extract/extract.go | 176 ++ vendor/github.com/go-pdfkit/extract/image.go | 188 ++ .../github.com/go-pdfkit/extract/program.go | 175 ++ .../go-pdfkit/extract/renovate.json | 4 + vendor/github.com/go-pdfkit/extract/run.go | 273 +++ vendor/github.com/go-pdfkit/extract/state.go | 134 ++ .../github.com/go-pdfkit/pdffont/.gitignore | 1 + vendor/github.com/go-pdfkit/pdffont/LICENSE | 29 + vendor/github.com/go-pdfkit/pdffont/README.md | 75 + .../github.com/go-pdfkit/pdffont/encoding.go | 302 +++ .../github.com/go-pdfkit/pdffont/extended.go | 71 + vendor/github.com/go-pdfkit/pdffont/font.go | 245 +++ vendor/github.com/go-pdfkit/pdffont/greek.go | 69 + vendor/github.com/go-pdfkit/pdffont/read.go | 223 +++ .../github.com/go-pdfkit/pdffont/tounicode.go | 252 +++ vendor/github.com/go-pdfkit/reader/.gitignore | 3 + vendor/github.com/go-pdfkit/reader/LICENSE | 29 + vendor/github.com/go-pdfkit/reader/README.md | 144 ++ vendor/github.com/go-pdfkit/reader/ccitt.go | 579 ++++++ vendor/github.com/go-pdfkit/reader/content.go | 339 ++++ vendor/github.com/go-pdfkit/reader/crypt.go | 572 ++++++ .../github.com/go-pdfkit/reader/document.go | 431 ++++ vendor/github.com/go-pdfkit/reader/encrypt.go | 365 ++++ vendor/github.com/go-pdfkit/reader/filter.go | 475 +++++ vendor/github.com/go-pdfkit/reader/inline.go | 155 ++ vendor/github.com/go-pdfkit/reader/lex.go | 340 ++++ vendor/github.com/go-pdfkit/reader/lzw.go | 86 + vendor/github.com/go-pdfkit/reader/object.go | 244 +++ vendor/github.com/go-pdfkit/reader/objstm.go | 128 ++ vendor/github.com/go-pdfkit/reader/parse.go | 295 +++ .../github.com/go-pdfkit/reader/predictor.go | 130 ++ vendor/github.com/go-pdfkit/reader/repair.go | 339 ++++ vendor/github.com/go-pdfkit/reader/writer.go | 432 ++++ vendor/github.com/go-pdfkit/reader/xref.go | 303 +++ vendor/modules.txt | 12 + 115 files changed, 26422 insertions(+), 43 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/implementer-log.md create mode 100644 docs/plans/p5a/01-kind-fetch.md create mode 100644 docs/plans/p5a/02-pdftext.md create mode 100644 docs/plans/p5a/03-dcache.md create mode 100644 docs/plans/p5a/04-pdfpool.md create mode 100644 docs/plans/p5a/05-pdfdoc.md create mode 100644 docs/plans/p5a/06-api.md create mode 100644 docs/plans/p5a/07-digest-cli-docs.md create mode 100644 docs/plans/p5a/README.md create mode 100644 docs/plans/p5a/_files/cmd/sift/fetchpdf_test.go create mode 100644 docs/plans/p5a/_files/internal/api/pdf_test.go create mode 100644 docs/plans/p5a/_files/internal/clean/kind_test.go create mode 100644 docs/plans/p5a/_files/internal/dcache/pdf_test.go create mode 100644 docs/plans/p5a/_files/internal/digest/pages_test.go create mode 100644 docs/plans/p5a/_files/internal/fetch/pdf_test.go create mode 100644 docs/plans/p5a/_files/internal/metrics/pdf_test.go create mode 100644 docs/plans/p5a/_files/internal/pdfdoc/pdfdoc_test.go create mode 100644 docs/plans/p5a/_files/internal/pdfpool/pdfpool_test.go create mode 100644 docs/plans/p5a/_files/internal/pdftest/pdftest.go create mode 100644 docs/plans/p5a/_files/internal/pdftext/pdftext_test.go create mode 100644 docs/plans/p5a/_files/tests/testdata/pdf/README.md create mode 100644 docs/plans/p5a/_files/tests/testdata/pdf/encrypted-owner.pdf create mode 100644 docs/plans/p5a/_files/tests/testdata/pdf/encrypted-user.pdf create mode 100644 tools.go create mode 100755 tools/run-plan.sh create mode 100644 vendor/github.com/go-opentype/opentype/.gitignore create mode 100644 vendor/github.com/go-opentype/opentype/LICENSE create mode 100644 vendor/github.com/go-opentype/opentype/README.md create mode 100644 vendor/github.com/go-opentype/opentype/advance.go create mode 100644 vendor/github.com/go-opentype/opentype/avar.go create mode 100644 vendor/github.com/go-opentype/opentype/barecff.go create mode 100644 vendor/github.com/go-opentype/opentype/cff.go create mode 100644 vendor/github.com/go-opentype/opentype/cff2.go create mode 100644 vendor/github.com/go-opentype/opentype/cffhint.go create mode 100644 vendor/github.com/go-opentype/opentype/cffstrings.go create mode 100644 vendor/github.com/go-opentype/opentype/charmap.go create mode 100644 vendor/github.com/go-opentype/opentype/cmap.go create mode 100644 vendor/github.com/go-opentype/opentype/descriptor.go create mode 100644 vendor/github.com/go-opentype/opentype/doc.go create mode 100644 vendor/github.com/go-opentype/opentype/face.go create mode 100644 vendor/github.com/go-opentype/opentype/font.go create mode 100644 vendor/github.com/go-opentype/opentype/fvar.go create mode 100644 vendor/github.com/go-opentype/opentype/gdef.go create mode 100644 vendor/github.com/go-opentype/opentype/glyf.go create mode 100644 vendor/github.com/go-opentype/opentype/glyphpath.go create mode 100644 vendor/github.com/go-opentype/opentype/gpos.go create mode 100644 vendor/github.com/go-opentype/opentype/gsub.go create mode 100644 vendor/github.com/go-opentype/opentype/gvar.go create mode 100644 vendor/github.com/go-opentype/opentype/hint.go create mode 100644 vendor/github.com/go-opentype/opentype/instance.go create mode 100644 vendor/github.com/go-opentype/opentype/kern.go create mode 100644 vendor/github.com/go-opentype/opentype/layout.go create mode 100644 vendor/github.com/go-opentype/opentype/math.go create mode 100644 vendor/github.com/go-opentype/opentype/metricvar.go create mode 100644 vendor/github.com/go-opentype/opentype/positional.go create mode 100644 vendor/github.com/go-opentype/opentype/raster.go create mode 100644 vendor/github.com/go-opentype/opentype/rawtable.go create mode 100644 vendor/github.com/go-opentype/opentype/reader.go create mode 100644 vendor/github.com/go-opentype/opentype/sfntbuild.go create mode 100644 vendor/github.com/go-opentype/opentype/shape.go create mode 100644 vendor/github.com/go-opentype/opentype/subset.go create mode 100644 vendor/github.com/go-opentype/opentype/subsetcff.go create mode 100644 vendor/github.com/go-opentype/opentype/tables.go create mode 100644 vendor/github.com/go-opentype/opentype/type1.go create mode 100644 vendor/github.com/go-opentype/opentype/type1exec.go create mode 100644 vendor/github.com/go-opentype/opentype/vertical.go create mode 100644 vendor/github.com/go-pdfkit/extract/.gitignore create mode 100644 vendor/github.com/go-pdfkit/extract/LICENSE create mode 100644 vendor/github.com/go-pdfkit/extract/README.md create mode 100644 vendor/github.com/go-pdfkit/extract/extract.go create mode 100644 vendor/github.com/go-pdfkit/extract/image.go create mode 100644 vendor/github.com/go-pdfkit/extract/program.go create mode 100644 vendor/github.com/go-pdfkit/extract/renovate.json create mode 100644 vendor/github.com/go-pdfkit/extract/run.go create mode 100644 vendor/github.com/go-pdfkit/extract/state.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/.gitignore create mode 100644 vendor/github.com/go-pdfkit/pdffont/LICENSE create mode 100644 vendor/github.com/go-pdfkit/pdffont/README.md create mode 100644 vendor/github.com/go-pdfkit/pdffont/encoding.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/extended.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/font.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/greek.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/read.go create mode 100644 vendor/github.com/go-pdfkit/pdffont/tounicode.go create mode 100644 vendor/github.com/go-pdfkit/reader/.gitignore create mode 100644 vendor/github.com/go-pdfkit/reader/LICENSE create mode 100644 vendor/github.com/go-pdfkit/reader/README.md create mode 100644 vendor/github.com/go-pdfkit/reader/ccitt.go create mode 100644 vendor/github.com/go-pdfkit/reader/content.go create mode 100644 vendor/github.com/go-pdfkit/reader/crypt.go create mode 100644 vendor/github.com/go-pdfkit/reader/document.go create mode 100644 vendor/github.com/go-pdfkit/reader/encrypt.go create mode 100644 vendor/github.com/go-pdfkit/reader/filter.go create mode 100644 vendor/github.com/go-pdfkit/reader/inline.go create mode 100644 vendor/github.com/go-pdfkit/reader/lex.go create mode 100644 vendor/github.com/go-pdfkit/reader/lzw.go create mode 100644 vendor/github.com/go-pdfkit/reader/object.go create mode 100644 vendor/github.com/go-pdfkit/reader/objstm.go create mode 100644 vendor/github.com/go-pdfkit/reader/parse.go create mode 100644 vendor/github.com/go-pdfkit/reader/predictor.go create mode 100644 vendor/github.com/go-pdfkit/reader/repair.go create mode 100644 vendor/github.com/go-pdfkit/reader/writer.go create mode 100644 vendor/github.com/go-pdfkit/reader/xref.go diff --git a/.gitignore b/.gitignore index 32e78fb..6de616b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /sift /tests/testdata/bakeoff-out/ /tests/testdata/bakeoff-raw/ +.state/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a2f1cab --- /dev/null +++ b/AGENTS.md @@ -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//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//` 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 ...`. 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: + +`| / | | done or stopped | | 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. diff --git a/Makefile b/Makefile index 7ceb911..dc03811 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ CGO_ENABLED ?= 0 GO ?= go 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 @@ -20,6 +20,13 @@ build: test: $(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: $(GO) vet ./... @if [ -n "$(STATICCHECK)" ]; then $(STATICCHECK) ./...; else echo "staticcheck not found; skipped"; fi diff --git a/docs/P5a.md b/docs/P5a.md index 0de2622..768ae40 100644 --- a/docs/P5a.md +++ b/docs/P5a.md @@ -61,9 +61,7 @@ Notes: **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). -- Keep it as a regression fixture: `tests/testdata/pdf/udhr-ru.pdf`, expected to fail today and - marked as such. -- File the upstream issue. +- File the upstream issue. No fixture is committed: the document is not ours to redistribute. - Pin `extract v0.3.0` / `reader v0.6.0` in `go.mod` and vendor them, as for every other dependency. ## 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 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 - document share one download and one extraction. +- **Single-flight:** keyed by (caller class, content hash). Concurrent requests for the same + 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 `429 busy` path. - **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 -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 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. - 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`):** @@ -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 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). -14. **The `udhr-ru` regression fixture** is present and marked as a known failure (skip with a - reason naming the upstream issue). +14. (Dropped: no third-party fixture is committed; the extract defect is tracked upstream.) ## 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. 4. Watch `sift_pdf_*` and tirith for a week. The success measure is that the `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. diff --git a/docs/implementer-log.md b/docs/implementer-log.md new file mode 100644 index 0000000..f4560b9 --- /dev/null +++ b/docs/implementer-log.md @@ -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 | +|---|---|---|---|---|---|---| diff --git a/docs/plans/p5a/01-kind-fetch.md b/docs/plans/p5a/01-kind-fetch.md new file mode 100644 index 0000000..121db03 --- /dev/null +++ b/docs/plans/p5a/01-kind-fetch.md @@ -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`. diff --git a/docs/plans/p5a/02-pdftext.md b/docs/plans/p5a/02-pdftext.md new file mode 100644 index 0000000..7d4e693 --- /dev/null +++ b/docs/plans/p5a/02-pdftext.md @@ -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. diff --git a/docs/plans/p5a/03-dcache.md b/docs/plans/p5a/03-dcache.md new file mode 100644 index 0000000..4f07416 --- /dev/null +++ b/docs/plans/p5a/03-dcache.md @@ -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. diff --git a/docs/plans/p5a/04-pdfpool.md b/docs/plans/p5a/04-pdfpool.md new file mode 100644 index 0000000..dbee087 --- /dev/null +++ b/docs/plans/p5a/04-pdfpool.md @@ -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`). diff --git a/docs/plans/p5a/05-pdfdoc.md b/docs/plans/p5a/05-pdfdoc.md new file mode 100644 index 0000000..673e7ca --- /dev/null +++ b/docs/plans/p5a/05-pdfdoc.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: + ``` + # + + pages · ~ tokens[ · pages 1- extracted] + + ## Outline (only when the outline is not empty) + - (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. diff --git a/docs/plans/p5a/06-api.md b/docs/plans/p5a/06-api.md new file mode 100644 index 0000000..636108a --- /dev/null +++ b/docs/plans/p5a/06-api.md @@ -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. diff --git a/docs/plans/p5a/07-digest-cli-docs.md b/docs/plans/p5a/07-digest-cli-docs.md new file mode 100644 index 0000000..b840f5f --- /dev/null +++ b/docs/plans/p5a/07-digest-cli-docs.md @@ -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. diff --git a/docs/plans/p5a/README.md b/docs/plans/p5a/README.md new file mode 100644 index 0000000..fb1fdfc --- /dev/null +++ b/docs/plans/p5a/README.md @@ -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) diff --git a/docs/plans/p5a/_files/cmd/sift/fetchpdf_test.go b/docs/plans/p5a/_files/cmd/sift/fetchpdf_test.go new file mode 100644 index 0000000..d8ee384 --- /dev/null +++ b/docs/plans/p5a/_files/cmd/sift/fetchpdf_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/internal/api/pdf_test.go b/docs/plans/p5a/_files/internal/api/pdf_test.go new file mode 100644 index 0000000..041978a --- /dev/null +++ b/docs/plans/p5a/_files/internal/api/pdf_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/internal/clean/kind_test.go b/docs/plans/p5a/_files/internal/clean/kind_test.go new file mode 100644 index 0000000..c79942c --- /dev/null +++ b/docs/plans/p5a/_files/internal/clean/kind_test.go @@ -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) + } + } +} diff --git a/docs/plans/p5a/_files/internal/dcache/pdf_test.go b/docs/plans/p5a/_files/internal/dcache/pdf_test.go new file mode 100644 index 0000000..2b71e00 --- /dev/null +++ b/docs/plans/p5a/_files/internal/dcache/pdf_test.go @@ -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") + } +} diff --git a/docs/plans/p5a/_files/internal/digest/pages_test.go b/docs/plans/p5a/_files/internal/digest/pages_test.go new file mode 100644 index 0000000..d1b5c03 --- /dev/null +++ b/docs/plans/p5a/_files/internal/digest/pages_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/internal/fetch/pdf_test.go b/docs/plans/p5a/_files/internal/fetch/pdf_test.go new file mode 100644 index 0000000..00f1b89 --- /dev/null +++ b/docs/plans/p5a/_files/internal/fetch/pdf_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/internal/metrics/pdf_test.go b/docs/plans/p5a/_files/internal/metrics/pdf_test.go new file mode 100644 index 0000000..6a8ffb8 --- /dev/null +++ b/docs/plans/p5a/_files/internal/metrics/pdf_test.go @@ -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") + } +} diff --git a/docs/plans/p5a/_files/internal/pdfdoc/pdfdoc_test.go b/docs/plans/p5a/_files/internal/pdfdoc/pdfdoc_test.go new file mode 100644 index 0000000..47bb287 --- /dev/null +++ b/docs/plans/p5a/_files/internal/pdfdoc/pdfdoc_test.go @@ -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)) + } +} diff --git a/docs/plans/p5a/_files/internal/pdfpool/pdfpool_test.go b/docs/plans/p5a/_files/internal/pdfpool/pdfpool_test.go new file mode 100644 index 0000000..4f86898 --- /dev/null +++ b/docs/plans/p5a/_files/internal/pdfpool/pdfpool_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/internal/pdftest/pdftest.go b/docs/plans/p5a/_files/internal/pdftest/pdftest.go new file mode 100644 index 0000000..62508de --- /dev/null +++ b/docs/plans/p5a/_files/internal/pdftest/pdftest.go @@ -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) +} diff --git a/docs/plans/p5a/_files/internal/pdftext/pdftext_test.go b/docs/plans/p5a/_files/internal/pdftext/pdftext_test.go new file mode 100644 index 0000000..aaeb80f --- /dev/null +++ b/docs/plans/p5a/_files/internal/pdftext/pdftext_test.go @@ -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) + } +} diff --git a/docs/plans/p5a/_files/tests/testdata/pdf/README.md b/docs/plans/p5a/_files/tests/testdata/pdf/README.md new file mode 100644 index 0000000..f4d46db --- /dev/null +++ b/docs/plans/p5a/_files/tests/testdata/pdf/README.md @@ -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). diff --git a/docs/plans/p5a/_files/tests/testdata/pdf/encrypted-owner.pdf b/docs/plans/p5a/_files/tests/testdata/pdf/encrypted-owner.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2aaddbff3a91867df7a966f3c75071ef4aa604cd GIT binary patch literal 3408 zcmc&%du&zp9e0^7;fArzncy~=XVWT?;r%_%?{VA(SNgyPuw4s9K#||;w3pr9)7^X8 zw3`#%G{I<aBW%-bX#$SOVA6$=u`M>nsLMd=!ln)wGs}u8Mp!bROd{@kPVa56Esj6E zX@2*2zrWY#`~I}9t+9C_zENcAo_cr3`3&}u7wL_eT+Yij4aBZk&PT<vm;9`6T+)<I zmZ-Apbfsj)%fiu`oy%RnD|pgdyg2#Gw)Vw^e9@IX3fH!K**4YXRz%i$*;QpXxtNN} zo<Ff?<zO$YH>g+@qAs{4<BB#xL~4+=l_J|`T*=EB>KtN%AR~#98YANj359W*X+xM$ zBA}8pY(q%{>9c@YCx}#rbAhzg0CHtME;uh2YVC&>HindA>T|}N6j*V@oWk59ESTlK zGJ)aLI)p-rwWf?yPQrjGoM>Fdny6c~VnftdjKKie=6tCfdu&3><qGD!R^v%?OkI;$ zWX3hi^0w@)^F(@9relzfcYrFX?Gh`OYtST5R<CJv@NV^V_O>-^y<%UXP&2?Sx4MDY z!zjHJd{c5M{?vgLTVX<}Zz(0A@_n$m#VjNN;?nsp;1~(QSzr}`bsln~oM2cZ#vKbB zL73OtDlx(|bQAh~1iB9cYAd<^NvsugmIk3vAwn8_iG}071n0?s3uO&>9ftwoI?z&R z7HZ^k%>#sx422AYFM?WhiJ7bD8loa3L|pc8k|hw%E1b=%CD8yFG_e-P#1jGWz=Rc$ zm5^?C<*2V@z$r-{HN!O%6YRl><fbH7Zfn&Wotao^>4Bw*oT+5elxJ%kn|OaFxXPm+ zH@L)!Gr?8D|KkRiMt2omB_}3w=_8^p&526BULXQ1YE=SI3lccYv4esmi3*KHJWz&O z7%A7G7M3YZAu>ZpDGd!nTx&)w(o_-aY6CEhwJOr(uGkfKL)6HmoD^cHfZ67Pid~~K zNm+%`QI1Pa^<+-m^|NPA^d5Qg#;af1vFOL+SDh8>etzbv;SKY?a$w|=m(SevO#7%b z53U~j)~xxTTJWfT^`}on{`xKdK6L+v{l6LB``6RI|ID5_`1(H{etGu#C-$Ehe%GP9 zvS;@``0MZ|?<^=?^WyGbZM}4LU(1#|?>_F<-+c2PwCbj(M?UxT&KLgi@ch@sWA;fX z+7%>~@|{L{Rga{Dq-l`7#TEKpoHq)ZND*|%7rVUd>U^;t8qbuVqff_aX-m=oSL<AQ z!&J3Q6K3*-d|YmGrG}`dH!3Ct3@p|V6{1o{uQJeI8eM<hxTPgE=mCa&S9k0YFFW~8 zOApl0>O0lU1Y8!Z5Bn;9pfLUaZdkJWz^H!Q?i%R$?k>A6_r{_9_k19)eWB%{uJ+Ek zZ`}IoOf%Ki{HnXbb{Bc)&7-X+Haz#~>rO7r-|x1cdgtNMl0HA@w`1Gc;nSy=-TC;X zO@o<do7c?yUT*9^U%GdwZuO4)x;OS8Ja_q~A8h&lwc@R@p>4nWTJNTw)(6Iq1hYHF zFWL3C7gwHHyYp0L<O}|De<^(8+kLmZbpF`L=FwL+JyhTD{7l@nO{R*Qql@7_D2;re z>%T9}`O}IGCv)RhG##IP!<y}xQ}z2k`Ptq3&OLj@(#!0To~=9o8U6iVx9?kX*v^`J z<-G7Rl70X4zg)U`xJz~J`SZO$+=?C>nO_{sexvEov17+(J#zK&AANo8wgs=<{)a`; z-l4bOI(fsE?&sgcuf4r!bZGMz4_tRNcyIihId;^XJ^R7YM-Fy9wegOVS1x;h?&U+H z*Sz=ox%*lM(cNGD^6<ucelb(e+IH2@oYwJ<*j8rseQ|eFe-dul#>lkxIv_|5%`1RA zHFb0X1(=GPN?ET)Pe-hZR+a4J7Q#5tCoe}G<r3-zbTB7nND<M-F<?9lGoM+@fPhdf z5!IX_0h~lAY@w48XBE&J%Angpxd?oNsS60uA*8s2V**xKU=orBtd1H(0*(TKB(|_D z5%fuEwJ#`!KFTBywXYfV3Gg0E45d~FzQR&z==pv^quRWMK8&u^18t6I9-1KGTp_Uv zhtdeCRA`M)l$Aok{&t{0*^aoUIT;fJ!dGeP$0}fCfyIF&NNB|v76hY^gZ_y6e%h%m zHx#&qLP}7LF?SqW;D8dqQ)>d|n6@Etf`;5u?lVFe@dF?Mh+Cj|3FuXzJ5V`peFxM9 zCFBK8aff^=fjtDA`bMIV0b2mlN0l_p0<WaNAPG~_rxXEQ$aVs)n<_*DB^PFpheZZh zMdjlGpQOwSy-68{j7dITaZ-jkG|g!;>I2!BCi9bGI$b7#FHDp13!D|f%r^oEwrOWE zPRi+r@54j@&6y_i{h2a;0hyrCU#Fc7z#`LR1mhX_z;87+h)XJ8NT@$hsg`0GK|M~% zSbG!!Yn_;P<iF#<{;;9ZTbIKY97{#gXc0Fe3^0WN7Czz#`S5ST;*0M9N(bGjG}sGI YOt65o)v8jQIvWx!$kf#}wKiw|2eL&5TL1t6 literal 0 HcmV?d00001 diff --git a/docs/plans/p5a/_files/tests/testdata/pdf/encrypted-user.pdf b/docs/plans/p5a/_files/tests/testdata/pdf/encrypted-user.pdf new file mode 100644 index 0000000..6e57733 --- /dev/null +++ b/docs/plans/p5a/_files/tests/testdata/pdf/encrypted-user.pdf @@ -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"¥G endstream +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 diff --git a/go.mod b/go.mod index f60ed7a..8613f36 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,11 @@ module git.wntrmute.dev/kyle/sift -go 1.26.0 +go 1.26.4 require ( 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/itchyny/gojq v0.12.19 github.com/pelletier/go-toml/v2 v2.4.3 @@ -21,6 +23,8 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // 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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 7f4af38..6eea046 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls= diff --git a/internal/api/api.go b/internal/api/api.go index c6f5ae4..b0960c2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -30,8 +30,8 @@ import ( const ( maxReqBody = 64 << 10 - writeTimeout = 120 * time.Second - requestDeadline = 90 * time.Second + writeTimeout = 240 * time.Second // P5a: a PDF may take 120 s to download + 20 s queued + 60 s to extract + requestDeadline = 210 * time.Second // P5a: the PDF path's worst case (see writeTimeout); other paths keep their own shorter timeouts ) type Server struct { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7afc4d6..1146683 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -222,8 +222,9 @@ func TestJSONJQ(t *testing.T) { func TestUnsupportedType(t *testing.T) { 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) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write([]byte("%PDF")) + // P5a: a PDF is supported now; a zip is still not. + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write([]byte("PK\x03\x04")) })) defer ts.Close() s, key := setupAPI(t, g) @@ -260,7 +261,7 @@ func TestJQTooLarge(t *testing.T) { func TestHTTPServerWriteTimeout(t *testing.T) { s := New("test", &guard.Guard{}, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 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) } } diff --git a/internal/clean/clean.go b/internal/clean/clean.go index fcb8e9d..b3aec4a 100644 --- a/internal/clean/clean.go +++ b/internal/clean/clean.go @@ -11,8 +11,8 @@ import ( "time" "unicode/utf8" - readability "github.com/go-shiori/go-readability" htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + readability "github.com/go-shiori/go-readability" "github.com/itchyny/gojq" "golang.org/x/net/html" "golang.org/x/net/html/charset" diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 0c0aac1..c25c55f 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -15,9 +15,9 @@ import ( type nopConn struct{} -func (nopConn) Control(func(uintptr)) error { return nil } -func (nopConn) Read(func(uintptr) bool) error { return nil } -func (nopConn) Write(func(uintptr) bool) error { return nil } +func (nopConn) Control(func(uintptr)) error { return nil } +func (nopConn) Read(func(uintptr) bool) error { return nil } +func (nopConn) Write(func(uintptr) bool) error { return nil } func mustAddr(s string) netip.Addr { a, err := netip.ParseAddr(s) @@ -38,10 +38,10 @@ func TestCheckAddrTable(t *testing.T) { {"1.1.1.1", false, ""}, {"93.184.216.34", false, ""}, {"2001:4860:4860::8888", false, ""}, - {"172.15.255.255", false, ""}, // just below 172.16/12 - {"172.32.0.1", false, ""}, // just above 172.16/12 - {"100.63.255.255", false, ""}, // just below CGNAT - {"100.128.0.1", false, ""}, // just above CGNAT + {"172.15.255.255", false, ""}, // just below 172.16/12 + {"172.32.0.1", false, ""}, // just above 172.16/12 + {"100.63.255.255", false, ""}, // just below CGNAT + {"100.128.0.1", false, ""}, // just above CGNAT {"127.0.0.1", 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. loopbacks := []string{ "http://127.0.0.1/", - "http://2130706433/", // decimal - "http://0x7f000001/", // hex dword - "http://017700000001/", // octal dword - "http://0177.0.0.1/", // octal dotted - "http://0x7f.0.0.1/", // hex dotted - "http://127.1/", // short - "http://127.0.1/", // 3-part + "http://2130706433/", // decimal + "http://0x7f000001/", // hex dword + "http://017700000001/", // octal dword + "http://0177.0.0.1/", // octal dotted + "http://0x7f.0.0.1/", // hex dotted + "http://127.1/", // short + "http://127.0.1/", // 3-part "http://0x7f.1/", } g := &Guard{} @@ -477,9 +477,9 @@ func mustURL(s string) *url.URL { // Ensure Control implements the Dialer hook shape used by net.Dialer. var ( - _ 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(req *http.Request, via []*http.Request) error = (*Guard)(nil).CheckRedirect + _ 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(req *http.Request, via []*http.Request) error = (*Guard)(nil).CheckRedirect ) func TestLocalhostName(t *testing.T) { @@ -544,20 +544,20 @@ func TestIANASpecialPurpose(t *testing.T) { t.Parallel() // One address per required prefix from the P0 review. blocked := []string{ - "2002:a00:1::1", // 6to4 of 10.0.0.1 - "2002:7f00:1::1", // 6to4 of 127.0.0.1 - "2001::1", // Teredo 2001::/32 (inside 2001::/23) + "2002:a00:1::1", // 6to4 of 10.0.0.1 + "2002:7f00:1::1", // 6to4 of 127.0.0.1 + "2001::1", // Teredo 2001::/32 (inside 2001::/23) "64:ff9b:1::a00:1", // RFC 8215 local-use NAT64 embedding 10.0.0.1 - "fec0::1", // deprecated site-local - "198.18.0.1", // benchmarking + "fec0::1", // deprecated site-local + "198.18.0.1", // benchmarking "198.19.255.255", "192.0.0.1", "240.0.0.1", "255.255.255.255", "100::1", "2001:db8::1", - "2001:10::1", // ORCHID - "2001:20::1", // ORCHIDv2 + "2001:10::1", // ORCHID + "2001:20::1", // ORCHIDv2 } for _, s := range blocked { t.Run(s, func(t *testing.T) { diff --git a/internal/guard/iana.go b/internal/guard/iana.go index f5c8dc7..834a58b 100644 --- a/internal/guard/iana.go +++ b/internal/guard/iana.go @@ -7,8 +7,9 @@ import "net/netip" // rather than decoded. IsGlobalUnicast remains a final gate in CheckAddr. // // 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 { prefix netip.Prefix reason string diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..40107ce --- /dev/null +++ b/tools.go @@ -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" +) diff --git a/tools/run-plan.sh b/tools/run-plan.sh new file mode 100755 index 0000000..e200c20 --- /dev/null +++ b/tools/run-plan.sh @@ -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" diff --git a/vendor/github.com/go-opentype/opentype/.gitignore b/vendor/github.com/go-opentype/opentype/.gitignore new file mode 100644 index 0000000..2213e21 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/.gitignore @@ -0,0 +1,2 @@ +coverage.out +*.out diff --git a/vendor/github.com/go-opentype/opentype/LICENSE b/vendor/github.com/go-opentype/opentype/LICENSE new file mode 100644 index 0000000..bffc6a5 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/LICENSE @@ -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. diff --git a/vendor/github.com/go-opentype/opentype/README.md b/vendor/github.com/go-opentype/opentype/README.md new file mode 100644 index 0000000..6290c04 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/README.md @@ -0,0 +1,204 @@ +# go-opentype/opentype + +[![CI](https://github.com/go-opentype/opentype/actions/workflows/ci.yml/badge.svg)](https://github.com/go-opentype/opentype/actions/workflows/ci.yml) +[![pkg.go.dev](https://img.shields.io/badge/pkg.go.dev-opentype-007d9c?logo=go&logoColor=white)](https://pkg.go.dev/github.com/go-opentype/opentype) +![coverage](https://img.shields.io/badge/coverage-100%25-1a7f37) +![go](https://img.shields.io/badge/Go-1.26.4%2B-00ADD8?logo=go&logoColor=white) +[![license](https://img.shields.io/badge/license-BSD--3--Clause-blue)](./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). diff --git a/vendor/github.com/go-opentype/opentype/advance.go b/vendor/github.com/go-opentype/opentype/advance.go new file mode 100644 index 0000000..d2f381b --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/advance.go @@ -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) +} diff --git a/vendor/github.com/go-opentype/opentype/avar.go b/vendor/github.com/go-opentype/opentype/avar.go new file mode 100644 index 0000000..c14878a --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/avar.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/barecff.go b/vendor/github.com/go-opentype/opentype/barecff.go new file mode 100644 index 0000000..8264a2e --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/barecff.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/cff.go b/vendor/github.com/go-opentype/opentype/cff.go new file mode 100644 index 0000000..b8513a0 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/cff.go @@ -0,0 +1,1202 @@ +// 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" + "strconv" +) + +// This file adds Compact Font Format (CFF) / Type2 charstring outline support, +// so OpenType "OTTO" fonts carry renderable outlines in the same contour/point +// representation that glyf.go produces. Type2 charstrings describe outlines +// with cubic Béziers; the interpreter flattens each cubic into on-curve line +// segments, yielding []contour whose points are all on-curve — exactly what the +// rasteriser (raster.go) consumes for a polygon. It is deliberately standalone: +// nothing here is wired into Font/Face; a caller drives parseCFF + outline. + +// cffTable is a parsed CFF table: the CharStrings INDEX (one charstring per +// glyph id), the global and local subroutine INDEXes with their computed +// biases, and the charstring type (only Type2 is supported). +type cffTable struct { + charStrings [][]byte + globalSubrs [][]byte + localSubrs [][]byte + gsubrBias int + lsubrBias int + charstringType int + sidToGid map[int]int // charset: string id -> glyph id (for seac) + priv *cffPrivate // Private-DICT hint parameters (blue zones, std widths) + + // What a font addressed by name rather than by number needs: the font's + // own strings, which glyph each one names, and the built-in encoding that + // says which glyph a byte stands for. + strings [][]byte + gidToSID []int + encoding map[byte]int // code -> glyph id; nil means the standard one + fontMatrix [6]float64 + isCID bool +} + +// glyphName is what the font calls a glyph: one of the names every CFF font +// shares, or one of its own. +func (c *cffTable) glyphName(gid int) (string, bool) { + if gid < 0 || gid >= len(c.gidToSID) { + return "", false + } + sid := c.gidToSID[gid] + if sid < nStdStrings { + return cffStandardStrings[sid], true + } + i := sid - nStdStrings + if i >= len(c.strings) { + return "", false + } + return string(c.strings[i]), true +} + +// glyphByCode maps a byte to a glyph through the font's own encoding, which is +// the Standard Encoding unless the font said otherwise. +func (c *cffTable) glyphByCode(code byte) (int, bool) { + if c.encoding != nil { + gid, ok := c.encoding[code] + return gid, ok + } + sid, ok := standardEncodingSID(int(code)) + if !ok { + return 0, false + } + gid, ok := c.sidToGid[int(sid)] + return gid, ok +} + +// maxT2Depth bounds callsubr/callgsubr recursion; deeper nesting is rejected as +// malformed. +const maxT2Depth = 10 + +// maxSeacDepth bounds endchar-seac accent composition recursion (a seac glyph +// whose base or accent is itself a seac), rejecting cycles as malformed. +const maxSeacDepth = 4 + +// maxT2Stack is the Type2 operand-stack limit (the spec permits 48 arguments). +const maxT2Stack = 48 + +// cffCurveSteps is the number of line segments a cubic Bézier is flattened into +// in font units. A fixed count keeps the interpreter branch-free here; the +// segments are re-scaled to device space by the rasteriser. +const cffCurveSteps = 8 + +// readOffset reads a size-byte (1..4) big-endian integer from r. +func readOffset(r *reader, size int) int { + v := 0 + for k := 0; k < size; k++ { + v = v<<8 | int(r.u8()) + } + return v +} + +// parseIndex decodes a CFF INDEX 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 parseIndex(r *reader) ([][]byte, error) { + count := int(r.u16()) + if r.err != nil { + return nil, fmt.Errorf("opentype: cff 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: cff 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: cff 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: cff index: item %d out of range", k) + } + items[k] = r.b[start:end] + } + r.pos = base + offsets[count] + return items, nil +} + +// parseDict decodes a CFF DICT into a map from operator to its operand list. +// Two-byte operators (escape byte 12) are keyed as 1200+second-byte. +func parseDict(b []byte) (map[int][]float64, error) { + d := map[int][]float64{} + var operands []float64 + i := 0 + for i < len(b) { + b0 := b[i] + if b0 <= 21 { + 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++ + } + 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 +} + +// parseDictOperand decodes one DICT operand starting at b[i], returning the +// value and the number of bytes it occupied. +func parseDictOperand(b []byte, i int) (float64, int, error) { + b0 := b[i] + switch { + case b0 == 28: + if i+3 > len(b) { + return 0, 0, fmt.Errorf("opentype: cff dict operand: %w", errTruncated) + } + return float64(int16(uint16(b[i+1])<<8 | uint16(b[i+2]))), 3, nil + case b0 == 29: + if i+5 > len(b) { + return 0, 0, fmt.Errorf("opentype: cff dict operand: %w", errTruncated) + } + u := uint32(b[i+1])<<24 | uint32(b[i+2])<<16 | uint32(b[i+3])<<8 | uint32(b[i+4]) + return float64(int32(u)), 5, nil + case b0 == 30: + return parseDictReal(b, i) + case b0 >= 32 && b0 <= 246: + return float64(int(b0) - 139), 1, nil + case b0 >= 247 && b0 <= 250: + if i+2 > len(b) { + return 0, 0, fmt.Errorf("opentype: cff dict operand: %w", errTruncated) + } + return float64((int(b0)-247)*256 + int(b[i+1]) + 108), 2, nil + case b0 >= 251 && b0 <= 254: + if i+2 > len(b) { + return 0, 0, fmt.Errorf("opentype: cff dict operand: %w", errTruncated) + } + return float64(-(int(b0)-251)*256 - int(b[i+1]) - 108), 2, nil + default: + return 0, 0, fmt.Errorf("opentype: cff dict: reserved operand byte %d", b0) + } +} + +// parseDictReal decodes a real-number DICT operand (leading byte 30, BCD nibble +// encoding) starting at b[i]. +func parseDictReal(b []byte, i int) (float64, int, error) { + var s []byte + j := i + 1 + done := false + for !done { + if j >= len(b) { + return 0, 0, fmt.Errorf("opentype: cff dict real: %w", errTruncated) + } + by := b[j] + j++ + for _, nib := range [2]byte{by >> 4, by & 0x0f} { + switch { + case nib <= 9: + s = append(s, '0'+nib) + case nib == 0x0a: + s = append(s, '.') + case nib == 0x0b: + s = append(s, 'E') + case nib == 0x0c: + s = append(s, 'E', '-') + case nib == 0x0e: + s = append(s, '-') + case nib == 0x0f: + done = true + default: + return 0, 0, fmt.Errorf("opentype: cff dict real: bad nibble %d", nib) + } + if done { + break + } + } + } + v, err := strconv.ParseFloat(string(s), 64) + if err != nil { + return 0, 0, fmt.Errorf("opentype: cff dict real %q: %w", s, err) + } + return v, j - i, nil +} + +// subrBias returns the Type2 subroutine-index bias for a subr INDEX of n +// entries (CFF spec: 107, 1131, or 32768). +func subrBias(n int) int { + if n < 1240 { + return 107 + } + if n < 33900 { + return 1131 + } + return 32768 +} + +// dictInt returns operand idx of operator op in d as an int, or ok=false when +// the operator is absent or has too few operands. +func dictInt(d map[int][]float64, op, idx int) (int, bool) { + v, ok := d[op] + if !ok || idx >= len(v) { + return 0, false + } + return int(v[idx]), true +} + +// parseCFF decodes a CFF table: Header, Name INDEX, Top DICT INDEX, String +// INDEX, Global Subr INDEX, then from the Top DICT the CharStrings INDEX, +// Private DICT (and its Local Subr INDEX) and CharstringType. +func parseCFF(data []byte) (*cffTable, 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) + } + r := &reader{b: data, pos: hdrSize} + if _, err := parseIndex(r); err != nil { // Name INDEX + return nil, err + } + topDicts, err := parseIndex(r) // Top DICT INDEX + if err != nil { + return nil, err + } + if len(topDicts) < 1 { + return nil, fmt.Errorf("opentype: cff: empty Top DICT INDEX") + } + strs, err := parseIndex(r) // String INDEX + if err != nil { + return nil, err + } + gsubrs, err := parseIndex(r) // Global Subr INDEX + if err != nil { + return nil, err + } + + top, err := parseDict(topDicts[0]) + if err != nil { + return nil, err + } + + cst := 2 + if t, ok := dictInt(top, 1206, 0); ok { + cst = t + } + if cst != 2 { + return nil, fmt.Errorf("opentype: cff: unsupported CharstringType %d", cst) + } + + csOff, ok := dictInt(top, 17, 0) + if !ok { + return nil, fmt.Errorf("opentype: cff: missing CharStrings offset") + } + if csOff < 0 || csOff > len(data) { + return nil, fmt.Errorf("opentype: cff: CharStrings offset out of range") + } + charStrings, err := parseIndex(&reader{b: data, pos: csOff}) + if err != nil { + return nil, err + } + + localSubrs, priv, err := parseLocalSubrs(data, top) + if err != nil { + return nil, err + } + + charsetOff, _ := dictInt(top, 15, 0) // absent -> 0 (ISOAdobe predefined) + sidToGid, err := parseCharset(data, charsetOff, len(charStrings)) + if err != nil { + return nil, err + } + + c := &cffTable{ + charStrings: charStrings, + globalSubrs: gsubrs, + localSubrs: localSubrs, + gsubrBias: subrBias(len(gsubrs)), + lsubrBias: subrBias(len(localSubrs)), + charstringType: cst, + sidToGid: sidToGid, + priv: priv, + strings: strs, + fontMatrix: fontMatrix(top), + } + _, c.isCID = top[1230] // ROS: the font is addressed by identifier, not by name + c.gidToSID = make([]int, len(charStrings)) + for sid, gid := range sidToGid { + if gid >= 0 && gid < len(c.gidToSID) { + c.gidToSID[gid] = sid + } + } + if encOff, ok := dictInt(top, 16, 0); ok { + if c.encoding, err = parseCFFEncoding(data, encOff, c); err != nil { + return nil, err + } + } + return c, nil +} + +// defaultFontMatrix is the one nearly every CFF font has: a thousand units to +// the em. +var defaultFontMatrix = [6]float64{0.001, 0, 0, 0.001, 0, 0} + +// fontMatrix reads the Top DICT's FontMatrix, which is what says how big the +// font's own units are. +func fontMatrix(top map[int][]float64) [6]float64 { + v, ok := top[1207] + if !ok || len(v) < 6 { + return defaultFontMatrix + } + var m [6]float64 + copy(m[:], v[:6]) + if m[0] == 0 { + return defaultFontMatrix + } + return m +} + +// parseCFFEncoding decodes the built-in encoding (Top DICT operator 16): which +// glyph each byte stands for. Offsets 0 and 1 name the two predefined ones; +// the standard one is what a nil map means, and the expert one is rare enough +// that it is read as standard rather than refused. +func parseCFFEncoding(data []byte, off int, c *cffTable) (map[byte]int, error) { + if off <= 1 { + return nil, nil + } + if off >= len(data) { + return nil, fmt.Errorf("opentype: cff encoding offset out of range") + } + r := &reader{b: data, pos: off} + format := r.u8() + out := map[byte]int{} + switch format &^ 0x80 { + case 0: + n := int(r.u8()) + for gid := 1; gid <= n; gid++ { + out[r.u8()] = gid + } + case 1: + nRanges := int(r.u8()) + gid := 1 + for i := 0; i < nRanges; i++ { + first := int(r.u8()) + nLeft := int(r.u8()) + for k := 0; k <= nLeft; k++ { + if code := first + k; code < 256 { + out[byte(code)] = gid + } + gid++ + } + } + default: + return nil, fmt.Errorf("opentype: cff encoding: unsupported format %d", format&^0x80) + } + if format&0x80 != 0 { + // Supplements name extra codes by string id rather than by glyph. + nSups := int(r.u8()) + for i := 0; i < nSups; i++ { + code := r.u8() + sid := int(r.u16()) + if gid, ok := c.sidToGid[sid]; ok { + out[code] = gid + } + } + } + if r.err != nil { + return nil, fmt.Errorf("opentype: cff encoding: %w", r.err) + } + return out, nil +} + +// parseCharset decodes the CFF charset (Top DICT operator 15) into a string-id +// -> glyph-id map, used to resolve endchar-seac base/accent glyph references. +// A predefined charset (offset 0, 1 or 2) or an absent operator is treated as +// the identity mapping (glyph i has string id i), which is exact for the +// ISOAdobe charset. Formats 0, 1 and 2 of an embedded charset are decoded. +func parseCharset(data []byte, off, nGlyphs int) (map[int]int, error) { + m := map[int]int{0: 0} // glyph 0 (.notdef) is always string id 0 + if off <= 2 { + for gid := 0; gid < nGlyphs; gid++ { + m[gid] = gid + } + return m, nil + } + if off > len(data) { + return nil, 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 { + m[int(r.u16())] = gid + gid++ + } + case 1: + for gid < nGlyphs { + first := int(r.u16()) + nLeft := int(r.u8()) + for i := 0; i <= nLeft && gid < nGlyphs; i++ { + m[first+i] = gid + gid++ + } + } + case 2: + for gid < nGlyphs { + first := int(r.u16()) + nLeft := int(r.u16()) + for i := 0; i <= nLeft && gid < nGlyphs; i++ { + m[first+i] = gid + gid++ + } + } + default: + return nil, fmt.Errorf("opentype: cff charset: unsupported format %d", format) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: cff charset: %w", r.err) + } + return m, nil +} + +// parseLocalSubrs resolves the Private DICT referenced by the Top DICT: it +// parses the Private-DICT hint parameters (blue zones, standard/snap stem +// widths) and, if the DICT declares a Subrs offset, decodes the Local Subr +// INDEX. A font with no Private DICT yields nil hints and no local subrs. +func parseLocalSubrs(data []byte, top map[int][]float64) ([][]byte, *cffPrivate, error) { + pv, ok := top[18] + if !ok { + return nil, nil, nil + } + if len(pv) < 2 { + return nil, nil, fmt.Errorf("opentype: cff: bad Private DICT entry") + } + psize, poff := int(pv[0]), int(pv[1]) + if psize < 0 || poff < 0 || poff+psize > len(data) { + return nil, nil, fmt.Errorf("opentype: cff: Private DICT out of range") + } + priv, err := parseDict(data[poff : poff+psize]) + if err != nil { + return nil, nil, err + } + hints := parsePrivateHints(priv) + soff, ok := dictInt(priv, 19, 0) + if !ok { + return nil, hints, nil + } + soff += poff + if soff < 0 || soff > len(data) { + return nil, nil, fmt.Errorf("opentype: cff: Local Subrs offset out of range") + } + ls, err := parseIndex(&reader{b: data, pos: soff}) + if err != nil { + return nil, nil, err + } + return ls, hints, nil +} + +// t2machine is the Type2 charstring interpreter state for one glyph: the +// operand stack, the pen position, the contour under construction and the +// finished contours, the accumulated stem-hint count (for hintmask byte +// sizing), and the width/endchar flags. +type t2machine struct { + c *cffTable + stack []float64 + x, y float64 + cur contour + contours []contour + nStems int + widthDone bool + done bool + seacDepth int // endchar-seac accent-composition recursion depth + + // Hinting data recorded during interpretation for the grid-fitter (cffhint.go): + // the resolved stem-edge positions in font units (in declaration order, which + // is the order hintmask/cntrmask bits index), the per-hintmask activation, the + // counter-control (cntrmask) stem groups, and the flex-region point ranges. + stemHints []cffStemHint + hintMasks []cffHintMask + cntrGroups [][]int + flexRanges []flexRange +} + +// outline interprets glyph gid's Type2 charstring into its outline as a set of +// contours in font units (all points on-curve, cubics pre-flattened). +func (c *cffTable) outline(gid int) ([]contour, error) { + return c.outlineSeac(gid, 0) +} + +// runGlyph interprets glyph gid's Type2 charstring and returns the finished +// interpreter, from which the outline (m.contours) and the recorded hinting +// data (m.stemHints, m.hintMasks) can both be read. seacDepth carries the +// endchar-seac recursion depth so a seac's components can be composed. +func (c *cffTable) runGlyph(gid, seacDepth int) (*t2machine, error) { + if gid < 0 || gid >= len(c.charStrings) { + return nil, fmt.Errorf("opentype: cff glyph %d out of range", gid) + } + m := &t2machine{c: c, seacDepth: seacDepth} + if err := m.run(c.charStrings[gid], 0); err != nil { + return nil, err + } + m.finishContour() + return m, nil +} + +// outlineSeac is outline with an explicit endchar-seac recursion depth so that +// a seac's base and accent components (themselves glyphs) can be composed. +func (c *cffTable) outlineSeac(gid, seacDepth int) ([]contour, error) { + m, err := c.runGlyph(gid, seacDepth) + if err != nil { + return nil, err + } + return m.contours, nil +} + +// outlineHints interprets glyph gid and returns its outline together with the +// hinting data (recorded stems, per-hintmask activation and the font's parsed +// Private-DICT hint parameters) that cffhint.go's grid-fitter consumes. +func (c *cffTable) outlineHints(gid int) ([]contour, *cffGlyphHints, error) { + m, err := c.runGlyph(gid, 0) + if err != nil { + return nil, nil, err + } + return m.contours, &cffGlyphHints{ + stems: m.stemHints, + hintMasks: m.hintMasks, + cntrGroups: m.cntrGroups, + flexRanges: m.flexRanges, + priv: c.priv, + }, nil +} + +// push appends v to the operand stack, guarding the stack-depth limit. +func (m *t2machine) push(v float64) error { + if len(m.stack) >= maxT2Stack { + return fmt.Errorf("opentype: cff: charstring stack overflow") + } + m.stack = append(m.stack, v) + return nil +} + +// clear empties the operand stack (every operator is stack-clearing in Type2). +func (m *t2machine) clear() { m.stack = m.stack[:0] } + +// width consumes a leading width operand on the operand stack the first time a +// width-bearing operator runs. expectEven states the parity of the operator's +// own argument count; a mismatch means a width value precedes the arguments. +func (m *t2machine) width(expectEven bool) { + if m.widthDone { + return + } + m.widthDone = true + if len(m.stack) > 0 && (len(m.stack)%2 == 0) != expectEven { + m.stack = m.stack[1:] + } +} + +// finishContour appends the contour under construction (if any) to the result. +func (m *t2machine) finishContour() { + if len(m.cur) > 0 { + m.contours = append(m.contours, m.cur) + m.cur = nil + } +} + +// moveTo closes the current contour and starts a new one at the relative point. +func (m *t2machine) moveTo(dx, dy float64) { + m.finishContour() + m.x += dx + m.y += dy + m.cur = contour{{x: m.x, y: m.y, on: true}} +} + +// lineTo adds a straight segment to the relative point. +func (m *t2machine) lineTo(dx, dy float64) { + m.x += dx + m.y += dy + m.cur = append(m.cur, outlinePoint{x: m.x, y: m.y, on: true}) +} + +// curveTo flattens a cubic Bézier from the pen position through control points +// (x1,y1),(x2,y2) to the endpoint (x3,y3), appending on-curve sample points. +func (m *t2machine) 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 +} + +// run executes a charstring (or subroutine) body. depth guards recursion. +func (m *t2machine) run(code []byte, depth int) error { + if depth > maxT2Depth { + return fmt.Errorf("opentype: cff: subroutine recursion too deep") + } + i := 0 + for i < len(code) { + b0 := code[i] + i++ + if b0 >= 32 || b0 == 28 { + v, n, err := m.operand(code, i-1) + if err != nil { + return err + } + if err := m.push(v); err != nil { + return err + } + i += n - 1 + continue + } + stop, ni, err := m.operator(b0, code, i, depth) + if err != nil { + return err + } + i = ni + if stop { + return nil + } + } + return nil +} + +// operand decodes a numeric charstring operand beginning at code[i], returning +// the value and its total byte length (including the leading byte). +func (m *t2machine) operand(code []byte, i int) (float64, int, error) { + b0 := code[i] + switch { + case b0 == 28: + if i+3 > len(code) { + return 0, 0, fmt.Errorf("opentype: cff charstring: %w", errTruncated) + } + return float64(int16(uint16(code[i+1])<<8 | uint16(code[i+2]))), 3, nil + case b0 == 255: + if i+5 > len(code) { + return 0, 0, fmt.Errorf("opentype: cff charstring: %w", errTruncated) + } + u := uint32(code[i+1])<<24 | uint32(code[i+2])<<16 | uint32(code[i+3])<<8 | uint32(code[i+4]) + return float64(int32(u)) / 65536.0, 5, nil + case b0 < 247: // 32..246 + return float64(int(b0) - 139), 1, nil + case b0 < 251: // 247..250 + if i+2 > len(code) { + return 0, 0, fmt.Errorf("opentype: cff charstring: %w", errTruncated) + } + return float64((int(b0)-247)*256 + int(code[i+1]) + 108), 2, nil + default: // 251..254 + if i+2 > len(code) { + return 0, 0, fmt.Errorf("opentype: cff charstring: %w", errTruncated) + } + return float64(-(int(b0)-251)*256 - int(code[i+1]) - 108), 2, nil + } +} + +// operator executes one Type2 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 interpretation should stop (return/endchar), +// and any error. +func (m *t2machine) operator(b0 byte, code []byte, i, depth int) (stop bool, ni int, err error) { + switch b0 { + case 1, 3, 18, 23: // hstem, vstem, hstemhm, vstemhm + m.stems(b0 == 1 || b0 == 18) + case 19, 20: // hintmask, cntrmask + m.stems(false) // any operands here are implicit vstem hints + ni, herr := m.readHintMask(code, i, b0 == 19) + if herr != nil { + return false, i, herr + } + i = ni + case 21: // rmoveto + m.width(true) + if len(m.stack) < 2 { + return false, i, fmt.Errorf("opentype: cff: rmoveto needs 2 args") + } + n := len(m.stack) + m.moveTo(m.stack[n-2], m.stack[n-1]) + m.clear() + case 22: // hmoveto + m.width(false) + if len(m.stack) < 1 { + return false, i, fmt.Errorf("opentype: cff: hmoveto needs 1 arg") + } + m.moveTo(m.stack[len(m.stack)-1], 0) + m.clear() + case 4: // vmoveto + m.width(false) + if len(m.stack) < 1 { + return false, i, fmt.Errorf("opentype: cff: vmoveto needs 1 arg") + } + m.moveTo(0, m.stack[len(m.stack)-1]) + m.clear() + case 5: // rlineto + s := m.stack + for j := 0; j+2 <= len(s); j += 2 { + m.lineTo(s[j], s[j+1]) + } + m.clear() + case 6: // hlineto + m.altLine(true) + case 7: // vlineto + m.altLine(false) + case 8: // rrcurveto + m.rrcurveto(m.stack) + m.clear() + case 24: // rcurveline + m.rcurveline(m.stack) + m.clear() + case 25: // rlinecurve + m.rlinecurve(m.stack) + m.clear() + case 26: // vvcurveto + m.vvcurveto(m.stack) + m.clear() + case 27: // hhcurveto + m.hhcurveto(m.stack) + m.clear() + case 30: // vhcurveto + m.altCurve(m.stack, false) + m.clear() + case 31: // hvcurveto + m.altCurve(m.stack, true) + m.clear() + case 10: // callsubr + return m.callSubr(m.c.localSubrs, m.c.lsubrBias, depth, i) + case 29: // callgsubr + return m.callSubr(m.c.globalSubrs, m.c.gsubrBias, depth, i) + case 11: // return + return true, i, nil + case 14: // endchar + m.width(true) + if len(m.stack) >= 4 { // deprecated seac: adx ady bchar achar + if err := m.seac(); err != nil { + return false, i, err + } + } + m.done = true + return true, i, nil + case 12: // escape: two-byte operator + if i >= len(code) { + return false, i, fmt.Errorf("opentype: cff charstring: truncated escape") + } + b1 := code[i] + i++ + if err := m.escape(b1); err != nil { + return false, i, err + } + default: + return false, i, fmt.Errorf("opentype: cff: unknown operator %d", b0) + } + return false, i, nil +} + +// stems handles a stem-hint operator: strip a leading width once, then record +// the declared stem pairs (which also advances the hint count and clears the +// stack). horizontal selects hstem (edges are Y positions) versus vstem (X). +func (m *t2machine) stems(horizontal bool) { + m.width(true) + m.recordStems(horizontal) +} + +// recordStems resolves the operand pairs into absolute stem-edge positions in +// font units and appends them to m.stemHints, advancing the hint count and +// clearing the stack. Each pair is (edgeDelta, width); the first edge is +// relative to the glyph origin and each subsequent edge is relative to the +// previous stem's far edge, per the Type2 stem-hint encoding. It records the +// positions without the leading-width handling that stems does, so it is safe +// to reuse from CFF2 (which has no per-glyph width). +func (m *t2machine) recordStems(horizontal bool) { + s := m.stack + pos := 0.0 + for j := 0; j+1 < len(s); j += 2 { + lo := pos + s[j] + hi := lo + s[j+1] + m.stemHints = append(m.stemHints, cffStemHint{horizontal: horizontal, min: lo, max: hi}) + pos = hi + } + m.nStems += len(s) / 2 + m.clear() +} + +// readHintMask consumes a hintmask/cntrmask operator's mask bytes at code[i] +// (one bit per declared stem, high bit first) and returns the stream index just +// past them. When record is true (a hintmask, operator 19) the activated stem +// set is captured together with the point count reached so far, so the +// grid-fitter can apply the hints active at each point (including a mask that +// changes mid-subpath). A cntrmask (operator 20, record false) records the set +// of stems it selects as one counter-control group, whose counters (the gaps +// between the group's stems) the grid-fitter equalises (see cffhint.go). +func (m *t2machine) readHintMask(code []byte, i int, record bool) (int, error) { + nb := (m.nStems + 7) / 8 + if i+nb > len(code) { + return i, fmt.Errorf("opentype: cff hintmask: %w", errTruncated) + } + active := make([]bool, m.nStems) + for k := 0; k < m.nStems; k++ { + if code[i+k/8]&(0x80>>(uint(k)%8)) != 0 { + active[k] = true + } + } + if record { + m.hintMasks = append(m.hintMasks, cffHintMask{pointIndex: m.pointCount(), active: active}) + } else { + var group []int + for k, on := range active { + if on { + group = append(group, k) + } + } + m.cntrGroups = append(m.cntrGroups, group) + } + return i + nb, nil +} + +// pointCount returns the number of outline points emitted so far (finished +// contours plus the contour under construction), used to stamp each hintmask +// with the subpath position at which it takes effect. +func (m *t2machine) pointCount() int { + n := len(m.cur) + for _, c := range m.contours { + n += len(c) + } + return n +} + +// callSubr executes the subroutine selected by the top-of-stack index (plus the +// bias) from subrs, returning the endchar-propagating stop flag. +func (m *t2machine) callSubr(subrs [][]byte, bias, depth, i int) (bool, int, error) { + if len(m.stack) < 1 { + return false, i, fmt.Errorf("opentype: cff: callsubr needs an index") + } + idx := int(m.stack[len(m.stack)-1]) + bias + m.stack = m.stack[:len(m.stack)-1] + if idx < 0 || idx >= len(subrs) { + return false, i, fmt.Errorf("opentype: cff: subr index %d out of range", idx) + } + if err := m.run(subrs[idx], depth+1); err != nil { + return false, i, err + } + return m.done, i, nil +} + +// escape executes a two-byte (escape) operator: the flex family. Every flex +// variant draws two shallow cubic curves; the point range they emit is recorded +// as a flexRange so the grid-fitter can keep the flex smooth instead of snapping +// its near-flat span to a stem edge (see cffhint.go). +func (m *t2machine) escape(b1 byte) error { + s := m.stack + startIdx := m.pointCount() + switch b1 { + case 34: // hflex: dx1 dx2 dy2 dx3 dx4 dx5 dx6 + if len(s) < 7 { + return fmt.Errorf("opentype: cff: hflex needs 7 args") + } + x1, y1 := m.x+s[0], m.y + x2, y2 := x1+s[1], y1+s[2] + x3, y3 := x2+s[3], y2 + m.curveTo(x1, y1, x2, y2, x3, y3) + x4, y4 := m.x+s[4], m.y + x5, y5 := x4+s[5], y4-s[2] + x6, y6 := x5+s[6], y5 + m.curveTo(x4, y4, x5, y5, x6, y6) + case 35: // flex: 12 deltas + fd + if len(s) < 12 { + return fmt.Errorf("opentype: cff: flex needs 13 args") + } + x1, y1 := m.x+s[0], m.y+s[1] + x2, y2 := x1+s[2], y1+s[3] + x3, y3 := x2+s[4], y2+s[5] + m.curveTo(x1, y1, x2, y2, x3, y3) + x4, y4 := m.x+s[6], m.y+s[7] + x5, y5 := x4+s[8], y4+s[9] + x6, y6 := x5+s[10], y5+s[11] + m.curveTo(x4, y4, x5, y5, x6, y6) + case 36: // hflex1: dx1 dy1 dx2 dy2 dx3 dx4 dx5 dy5 dx6 + if len(s) < 9 { + return fmt.Errorf("opentype: cff: hflex1 needs 9 args") + } + sy := m.y + x1, y1 := m.x+s[0], m.y+s[1] + x2, y2 := x1+s[2], y1+s[3] + x3, y3 := x2+s[4], y2 + m.curveTo(x1, y1, x2, y2, x3, y3) + x4, y4 := m.x+s[5], m.y + x5, y5 := x4+s[6], y4+s[7] + x6, y6 := x5+s[8], sy + m.curveTo(x4, y4, x5, y5, x6, y6) + case 37: // flex1: 10 deltas + d6 + if len(s) < 11 { + return fmt.Errorf("opentype: cff: flex1 needs 11 args") + } + sx, sy := m.x, m.y + dx := s[0] + s[2] + s[4] + s[6] + s[8] + dy := s[1] + s[3] + s[5] + s[7] + s[9] + x1, y1 := m.x+s[0], m.y+s[1] + x2, y2 := x1+s[2], y1+s[3] + x3, y3 := x2+s[4], y2+s[5] + m.curveTo(x1, y1, x2, y2, x3, y3) + x4, y4 := m.x+s[6], m.y+s[7] + x5, y5 := x4+s[8], y4+s[9] + var x6, y6 float64 + if math.Abs(dx) > math.Abs(dy) { + x6, y6 = x5+s[10], sy + } else { + x6, y6 = sx, y5+s[10] + } + m.curveTo(x4, y4, x5, y5, x6, y6) + case 0: + // dotsection: a Type 1 hint operator that says a dot stands alone. + // It has no meaning here, but fonts converted from Type 1 still + // carry it — and refusing it takes the dot off every i, j, colon and + // semicolon in such a font, which is exactly what it was found doing. + m.clear() + default: + return fmt.Errorf("opentype: cff: unknown escape operator %d", b1) + } + m.flexRanges = append(m.flexRanges, flexRange{start: startIdx, end: m.pointCount() - 1}) + m.clear() + return nil +} + +// rrcurveto draws successive cubic curves from groups of six relative deltas. +func (m *t2machine) rrcurveto(s []float64) { + for j := 0; j+6 <= len(s); j += 6 { + m.relCurve(s[j], s[j+1], s[j+2], s[j+3], s[j+4], s[j+5]) + } +} + +// rcurveline draws cubic curves then a final straight line (trailing pair). +func (m *t2machine) rcurveline(s []float64) { + j := 0 + for ; j+6 <= len(s)-2; j += 6 { + m.relCurve(s[j], s[j+1], s[j+2], s[j+3], s[j+4], s[j+5]) + } + m.lineTo(s[j], s[j+1]) +} + +// rlinecurve draws straight lines then a final cubic curve (trailing six). +func (m *t2machine) rlinecurve(s []float64) { + j := 0 + for ; j+2 <= len(s)-6; j += 2 { + m.lineTo(s[j], s[j+1]) + } + m.relCurve(s[j], s[j+1], s[j+2], s[j+3], s[j+4], s[j+5]) +} + +// relCurve draws one cubic from six relative deltas (two control points then +// the endpoint), each relative to the previous. +func (m *t2machine) relCurve(dxa, dya, dxb, dyb, dxc, dyc float64) { + x1, y1 := m.x+dxa, m.y+dya + x2, y2 := x1+dxb, y1+dyb + x3, y3 := x2+dxc, y2+dyc + m.curveTo(x1, y1, x2, y2, x3, y3) +} + +// hhcurveto draws curves whose start and end tangents are horizontal, with an +// optional leading dy1 for the first curve. +func (m *t2machine) hhcurveto(s []float64) { + j := 0 + dy1 := 0.0 + if len(s)%4 == 1 { + dy1 = s[0] + j = 1 + } + for ; j+4 <= len(s); j += 4 { + x1, y1 := m.x+s[j], m.y+dy1 + x2, y2 := x1+s[j+1], y1+s[j+2] + x3, y3 := x2+s[j+3], y2 + m.curveTo(x1, y1, x2, y2, x3, y3) + dy1 = 0 + } +} + +// vvcurveto draws curves whose start and end tangents are vertical, with an +// optional leading dx1 for the first curve. +func (m *t2machine) vvcurveto(s []float64) { + j := 0 + dx1 := 0.0 + if len(s)%4 == 1 { + dx1 = s[0] + j = 1 + } + for ; j+4 <= len(s); j += 4 { + x1, y1 := m.x+dx1, m.y+s[j] + x2, y2 := x1+s[j+1], y1+s[j+2] + x3, y3 := x2, y2+s[j+3] + m.curveTo(x1, y1, x2, y2, x3, y3) + dx1 = 0 + } +} + +// altCurve implements hvcurveto (startHoriz true) and vhcurveto (false), whose +// curves alternate between horizontal and vertical start tangents, with an +// optional extra final delta on the last curve. +func (m *t2machine) altCurve(s []float64, startHoriz bool) { + j := 0 + horiz := startHoriz + for len(s)-j >= 4 { + last := 0.0 + if len(s)-j == 5 { + last = s[j+4] + } + if horiz { + x1, y1 := m.x+s[j], m.y + x2, y2 := x1+s[j+1], y1+s[j+2] + x3, y3 := x2+last, y2+s[j+3] + m.curveTo(x1, y1, x2, y2, x3, y3) + } else { + x1, y1 := m.x, m.y+s[j] + x2, y2 := x1+s[j+1], y1+s[j+2] + x3, y3 := x2+s[j+3], y2+last + m.curveTo(x1, y1, x2, y2, x3, y3) + } + j += 4 + horiz = !horiz + } +} + +// altLine implements hlineto (startHoriz true) and vlineto (false), drawing +// alternating horizontal/vertical segments, one per operand. +func (m *t2machine) altLine(startHoriz bool) { + horiz := startHoriz + for _, d := range m.stack { + if horiz { + m.lineTo(d, 0) + } else { + m.lineTo(0, d) + } + horiz = !horiz + } + m.clear() +} + +// seac composes the accented glyph described by the four endchar-seac operands +// (adx ady bchar achar) left on the stack: the base glyph's outline plus the +// accent glyph's outline shifted by (adx, ady). bchar and achar are Standard +// Encoding codes, resolved to glyphs via the Standard Encoding and the CFF +// charset. Any outline the seac charstring drew itself is retained. +func (m *t2machine) seac() error { + if m.seacDepth >= maxSeacDepth { + return fmt.Errorf("opentype: cff seac: composition nesting too deep") + } + n := len(m.stack) + adx, ady := m.stack[n-4], m.stack[n-3] + bchar, achar := int(m.stack[n-2]), int(m.stack[n-1]) + base, err := m.c.seacComponent(bchar, m.seacDepth+1) + if err != nil { + return err + } + accent, err := m.c.seacComponent(achar, m.seacDepth+1) + if err != nil { + return err + } + m.finishContour() + m.contours = append(m.contours, base...) + for _, cont := range accent { + nc := make(contour, len(cont)) + for i, p := range cont { + nc[i] = outlinePoint{x: p.x + adx, y: p.y + ady, on: p.on} + } + m.contours = append(m.contours, nc) + } + m.clear() + return nil +} + +// seacComponent resolves a Standard Encoding code to a glyph id (via the +// standard encoding and the CFF charset) and returns that glyph's outline. +func (c *cffTable) seacComponent(code, seacDepth int) ([]contour, error) { + sid, ok := standardEncodingSID(code) + if !ok { + return nil, fmt.Errorf("opentype: cff seac: code %d not in standard encoding", code) + } + gid, ok := c.sidToGid[int(sid)] + if !ok { + return nil, fmt.Errorf("opentype: cff seac: no glyph for string id %d", sid) + } + return c.outlineSeac(gid, seacDepth) +} + +// standardEncodingSID maps a Standard Encoding character code to its CFF +// standard-string id (SID). For the printable ASCII range 32..126 the SID is +// simply code-31; the punctuation/accent codes 161..251 use a lookup table. +// ok is false for codes with no Standard Encoding glyph. +func standardEncodingSID(code int) (uint16, bool) { + if code >= 32 && code <= 126 { + return uint16(code - 31), true + } + sid, ok := stdEncodingHigh[code] + return sid, ok +} + +// stdEncodingHigh holds the Standard Encoding codes above the printable ASCII +// range (161..251) and their CFF standard-string ids. +var stdEncodingHigh = map[int]uint16{ + 161: 96, 162: 97, 163: 98, 164: 99, 165: 100, 166: 101, 167: 102, 168: 103, + 169: 104, 170: 105, 171: 106, 172: 107, 173: 108, 174: 109, 175: 110, + 177: 111, 178: 112, 179: 113, 180: 114, 182: 115, 183: 116, 184: 117, + 185: 118, 186: 119, 187: 120, 188: 121, 189: 122, 191: 123, 193: 124, + 194: 125, 195: 126, 196: 127, 197: 128, 198: 129, 199: 130, 200: 131, + 202: 132, 203: 133, 205: 134, 206: 135, 207: 136, 208: 137, 225: 138, + 227: 139, 232: 140, 233: 141, 234: 142, 235: 143, 241: 144, 245: 145, + 248: 146, 249: 147, 250: 148, 251: 149, +} + +// GlyphIndexByCID maps a character identifier to a glyph, for a CFF font +// addressed by identifier rather than by name. +// +// A composite font in a PDF names its glyphs by identifier, and how an +// identifier reaches a glyph depends on which sort of font program carries +// them. A TrueType-based one says so in a map of its own, and where it says +// nothing the identifier is the glyph number. A CFF-based one says nothing of +// the kind: the mapping lives in the font program's own charset, which for such +// a font lists, for each glyph in order, the identifier it stands for. +// +// Taking the identifier for the glyph number, as a reader must for the other +// sort, is wrong here by construction, and wrong in the worse of the two ways: +// when the number lands past the end of the font nothing is drawn, but when it +// lands inside it — which it usually does — a real glyph is drawn and it is the +// wrong one. Of the CID-keyed CFF fonts found on pages that came out blank, +// every one of fifty-five had a charset that was not the identity; of those in +// the figure corpus, twenty-one of forty-seven. +// +// ok is false for a font that is not addressed by identifier, and for one whose +// charset does not name that identifier at all. +func (f *Font) GlyphIndexByCID(cid int) (GlyphIndex, bool) { + if f.cff == nil || !f.cff.isCID || cid < 0 { + return 0, false + } + gid, ok := f.cff.sidToGid[cid] + if !ok { + return 0, false + } + return GlyphIndex(gid), true +} + +// IsCIDKeyed says whether the font program is addressed by identifier rather +// than by name, which is what decides how a composite font's identifiers reach +// its glyphs. +func (f *Font) IsCIDKeyed() bool { return f.cff != nil && f.cff.isCID } diff --git a/vendor/github.com/go-opentype/opentype/cff2.go b/vendor/github.com/go-opentype/opentype/cff2.go new file mode 100644 index 0000000..c9bc0a2 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/cff2.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/cffhint.go b/vendor/github.com/go-opentype/opentype/cffhint.go new file mode 100644 index 0000000..7918356 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/cffhint.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/cffstrings.go b/vendor/github.com/go-opentype/opentype/cffstrings.go new file mode 100644 index 0000000..80efab8 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/cffstrings.go @@ -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) diff --git a/vendor/github.com/go-opentype/opentype/charmap.go b/vendor/github.com/go-opentype/opentype/charmap.go new file mode 100644 index 0000000..ff53cfc --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/charmap.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/cmap.go b/vendor/github.com/go-opentype/opentype/cmap.go new file mode 100644 index 0000000..6c76ffd --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/cmap.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/descriptor.go b/vendor/github.com/go-opentype/opentype/descriptor.go new file mode 100644 index 0000000..23a5797 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/descriptor.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/doc.go b/vendor/github.com/go-opentype/opentype/doc.go new file mode 100644 index 0000000..5b442d6 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/doc.go @@ -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 diff --git a/vendor/github.com/go-opentype/opentype/face.go b/vendor/github.com/go-opentype/opentype/face.go new file mode 100644 index 0000000..6df87f0 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/face.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/font.go b/vendor/github.com/go-opentype/opentype/font.go new file mode 100644 index 0000000..496cfec --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/font.go @@ -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) +} diff --git a/vendor/github.com/go-opentype/opentype/fvar.go b/vendor/github.com/go-opentype/opentype/fvar.go new file mode 100644 index 0000000..4c8d7d4 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/fvar.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/gdef.go b/vendor/github.com/go-opentype/opentype/gdef.go new file mode 100644 index 0000000..e7137d7 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/gdef.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/glyf.go b/vendor/github.com/go-opentype/opentype/glyf.go new file mode 100644 index 0000000..ac4c43c --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/glyf.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/glyphpath.go b/vendor/github.com/go-opentype/opentype/glyphpath.go new file mode 100644 index 0000000..f8dcc44 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/glyphpath.go @@ -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, ".") +} diff --git a/vendor/github.com/go-opentype/opentype/gpos.go b/vendor/github.com/go-opentype/opentype/gpos.go new file mode 100644 index 0000000..2ce69e1 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/gpos.go @@ -0,0 +1,1653 @@ +// 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 and applies the GPOS (Glyph Positioning) table. It supports +// the full set of positioning lookup types complex scripts rely on: +// +// - Type 1: single adjustment (formats 1 and 2). +// - Type 2: pair adjustment / kerning (formats 1 and 2). +// - Type 3: cursive attachment (entry/exit anchors). +// - Type 4: mark-to-base attachment (diacritics on a base glyph). +// - Type 5: mark-to-ligature attachment. +// - Type 6: mark-to-mark attachment (stacked diacritics). +// - Type 7: contextual positioning (formats 1, 2 and 3). +// - Type 8: chaining contextual positioning (formats 1, 2 and 3). +// - Type 9: extension positioning (indirection to another lookup type). +// +// Anchor tables in formats 1, 2 and 3 are decoded; the anchor-point index of +// format 2 and the Device/VariationIndex offsets of format 3 are tolerated and +// ignored (their contribution is a hinting/variation refinement). +// +// The class-based contextual sub-format (format 2 of types 7 and 8) classifies +// the glyph run through ClassDef tables and matches rules by class sequence, +// mirroring the class-based GSUB contextual/chaining subtables. Every +// sub-format is implemented. +// +// Two entry points are exposed for the package's Face API to call: Kern (and +// Kerner.Kerning) for horizontal pair kerning, and position, which applies the +// full set of lookups over a glyph run and returns per-glyph placement and +// advance adjustments. + +// gpos is a decoded GPOS table. +type gpos struct { + layoutHeader + lookups []gposLookup + gdef *gdefTable // font GDEF (nil when absent); drives glyph skipping +} + +// skipperFor builds the glyph skipper for lookup lk from the table's GDEF and +// the lookup's flag and mark-filtering set. +func (p *gpos) skipperFor(lk gposLookup) skipper { + return skipper{gdef: p.gdef, flag: lk.flag, markFilterSet: lk.markSet} +} + +// gposLookup is one decoded GPOS Lookup: its supported positioning subtables. +// flag is the lookup's lookupFlag and markSet its mark-filtering-set index; +// with the font's GDEF table they drive which glyphs the lookup skips. +type gposLookup struct { + subtables []posSubtable + flag uint16 + markSet uint16 +} + +// posSubtable applies a positioning adjustment at index i of a glyph run whose +// current per-glyph adjustments live in the context. It reports whether it +// applied, so lookup iteration knows to stop trying further subtables. +type posSubtable interface { + apply(ctx *posContext, i int) bool +} + +// pairPos is a positioning subtable that can also answer a standalone +// (left, right) X-advance kern query; ok is false when it has no entry for the +// pair. Only the pair-adjustment subtables (type 2) implement it. +type pairPos interface { + posSubtable + kern(left, right GlyphIndex) (int, bool) +} + +// GlyphPosition is the positioning adjustment GPOS computes for one glyph in a +// run: pen placement offsets (XOffset, YOffset) and advance adjustments +// (XAdvance, YAdvance), all in font units. +type GlyphPosition struct { + XOffset int + YOffset int + XAdvance int + YAdvance int +} + +// valueRecord holds the four positioning fields of a GPOS ValueRecord; Device +// and VariationIndex offsets are consumed during parsing but not retained. +type valueRecord struct { + xPlacement int + yPlacement int + xAdvance int + yAdvance int +} + +// addValue accumulates a ValueRecord into a glyph's adjustment. +func (p *GlyphPosition) addValue(v valueRecord) { + p.XOffset += v.xPlacement + p.YOffset += v.yPlacement + p.XAdvance += v.xAdvance + p.YAdvance += v.yAdvance +} + +// readValueRecord consumes a ValueRecord whose shape is given by format, +// returning its four positioning fields. Present fields are 2 bytes each and +// appear in ValueFormat bit order; the four Device/VariationIndex offset fields +// are read and discarded. +func readValueRecord(r *reader, format uint16) valueRecord { + var v valueRecord + if format&0x0001 != 0 { + v.xPlacement = int(r.i16()) + } + if format&0x0002 != 0 { + v.yPlacement = int(r.i16()) + } + if format&0x0004 != 0 { + v.xAdvance = int(r.i16()) + } + if format&0x0008 != 0 { + v.yAdvance = int(r.i16()) + } + if format&0x0010 != 0 { + r.u16() // X placement device + } + if format&0x0020 != 0 { + r.u16() // Y placement device + } + if format&0x0040 != 0 { + r.u16() // X advance device + } + if format&0x0080 != 0 { + r.u16() // Y advance device + } + return v +} + +// parseGPOS decodes a GPOS table. +func parseGPOS(b []byte) (*gpos, error) { + hdr, raw, err := parseLayoutCommon(b) + if err != nil { + return nil, err + } + lookups := make([]gposLookup, len(raw)) + for i, lk := range raw { + gl, err := parseGPOSLookup(lk) + if err != nil { + return nil, err + } + lookups[i] = gl + } + return &gpos{layoutHeader: hdr, lookups: lookups}, nil +} + +// parseGPOSLookup decodes one Lookup table into its supported subtables. +func parseGPOSLookup(b []byte) (gposLookup, error) { + r := reader{b: b} + lookupType := r.u16() + flag := r.u16() + subCount := int(r.u16()) + offs := make([]int, subCount) + for i := 0; i < subCount; i++ { + offs[i] = int(r.u16()) + } + var markSet uint16 + if flag&flagUseMarkFilteringSet != 0 { + markSet = r.u16() // markFilteringSet follows the subtable offsets + } + if r.err != nil { + return gposLookup{}, fmt.Errorf("opentype: gpos lookup: %w", r.err) + } + var subs []posSubtable + for _, off := range offs { + st, err := parseGPOSSubtable(lookupType, subslice(b, off)) + if err != nil { + return gposLookup{}, err + } + if st != nil { + subs = append(subs, st) + } + } + return gposLookup{subtables: subs, flag: flag, markSet: markSet}, nil +} + +// parseGPOSSubtable decodes one subtable of the given lookup type. A deferred +// sub-format yields a nil subtable the caller drops. +func parseGPOSSubtable(lookupType uint16, b []byte) (posSubtable, error) { + switch lookupType { + case 1: + return parseSinglePos(b) + case 2: + return parsePairPosAny(b) + case 3: + return parseCursivePos(b) + case 4: + return parseMarkBasePos(b) + case 5: + return parseMarkLigPos(b) + case 6: + return parseMarkMarkPos(b) + case 7: + return parseContextPos(b) + case 8: + return parseChainPos(b) + case 9: + return parseExtensionPos(b) + default: + return nil, fmt.Errorf("opentype: gpos lookup type %d", lookupType) + } +} + +// --- Anchor tables ---------------------------------------------------------- + +// anchor is a decoded Anchor table's design-space coordinate. +type anchor struct { + x int + y int +} + +// parseAnchor decodes an Anchor table (format 1, 2 or 3). Format 2's anchor +// point and format 3's Device/VariationIndex offsets are read and ignored. +func parseAnchor(b []byte) (anchor, error) { + r := reader{b: b} + format := r.u16() + a := anchor{x: int(r.i16()), y: int(r.i16())} + switch format { + case 1: + case 2: + r.u16() // anchorPoint (ignored) + case 3: + r.u16() // xDeviceOffset (ignored) + r.u16() // yDeviceOffset (ignored) + default: + return anchor{}, fmt.Errorf("opentype: anchor format %d", format) + } + if r.err != nil { + return anchor{}, fmt.Errorf("opentype: anchor: %w", r.err) + } + return a, nil +} + +// parseAnchorAt decodes the Anchor table at offset off from the start of b, +// returning nil for a NULL (zero) offset. +func parseAnchorAt(b []byte, off int) (*anchor, error) { + if off == 0 { + return nil, nil + } + a, err := parseAnchor(subslice(b, off)) + if err != nil { + return nil, err + } + return &a, nil +} + +// parseAnchorMatrix decodes a rows*cols grid of Anchor offsets (a BaseArray, +// Mark2Array or LigatureAttach body): a rowCount uint16 followed by +// rowCount*cols anchor offsets relative to the start of b. NULL offsets yield a +// nil anchor. +func parseAnchorMatrix(b []byte, cols int) ([][]*anchor, error) { + r := reader{b: b} + rows := int(r.u16()) + offs := make([][]int, rows) + for i := 0; i < rows; i++ { + offs[i] = make([]int, cols) + for j := 0; j < cols; j++ { + offs[i][j] = int(r.u16()) + } + } + if r.err != nil { + return nil, fmt.Errorf("opentype: anchor matrix: %w", r.err) + } + out := make([][]*anchor, rows) + for i := 0; i < rows; i++ { + out[i] = make([]*anchor, cols) + for j := 0; j < cols; j++ { + a, err := parseAnchorAt(b, offs[i][j]) + if err != nil { + return nil, err + } + out[i][j] = a + } + } + return out, nil +} + +// markRecord binds a mark glyph's class to its attachment anchor. +type markRecord struct { + class uint16 + anch anchor +} + +// parseMarkArray decodes a MarkArray table (markCount records, each a class and +// an Anchor offset). +func parseMarkArray(b []byte) ([]markRecord, error) { + r := reader{b: b} + n := int(r.u16()) + type mrec struct { + class uint16 + off int + } + recs := make([]mrec, n) + for i := 0; i < n; i++ { + recs[i].class = r.u16() + recs[i].off = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: markArray: %w", r.err) + } + out := make([]markRecord, n) + for i, rc := range recs { + a, err := parseAnchor(subslice(b, rc.off)) + if err != nil { + return nil, err + } + out[i] = markRecord{class: rc.class, anch: a} + } + return out, nil +} + +// --- Type 1: single adjustment ---------------------------------------------- + +// singlePos1 applies one ValueRecord to every covered glyph. +type singlePos1 struct { + cov map[GlyphIndex]int + val valueRecord +} + +// singlePos2 applies a per-glyph ValueRecord indexed by coverage index. +type singlePos2 struct { + cov map[GlyphIndex]int + vals []valueRecord +} + +// parseSinglePos decodes a SinglePos subtable (format 1 or 2). +func parseSinglePos(b []byte) (posSubtable, error) { + r := reader{b: b} + format := r.u16() + covOff := int(r.u16()) + valueFormat := r.u16() + switch format { + case 1: + v := readValueRecord(&r, valueFormat) + if r.err != nil { + return nil, fmt.Errorf("opentype: singlepos1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + return &singlePos1{cov: cov, val: v}, nil + case 2: + n := int(r.u16()) + vals := make([]valueRecord, n) + for i := 0; i < n; i++ { + vals[i] = readValueRecord(&r, valueFormat) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: singlepos2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + return &singlePos2{cov: cov, vals: vals}, nil + default: + return nil, fmt.Errorf("opentype: singlepos format %d", format) + } +} + +func (s *singlePos1) apply(ctx *posContext, i int) bool { + if _, ok := s.cov[ctx.glyphs[i]]; !ok { + return false + } + ctx.positions[i].addValue(s.val) + return true +} + +func (s *singlePos2) apply(ctx *posContext, i int) bool { + ci, ok := s.cov[ctx.glyphs[i]] + if !ok { + return false + } + if ci >= len(s.vals) { + return false + } + ctx.positions[i].addValue(s.vals[ci]) + return true +} + +// --- Type 2: pair adjustment (kerning) -------------------------------------- + +// parsePairPosAny decodes a PairPos subtable (format 1 or 2). +func parsePairPosAny(b []byte) (posSubtable, error) { + r := reader{b: b} + format := r.u16() + switch format { + case 1: + return parsePairPos1(b) + case 2: + return parsePairPos2(b) + default: + return nil, fmt.Errorf("opentype: pairpos format %d", format) + } +} + +// pairPos1 is a pair-adjustment format 1 subtable: covered first glyphs index +// into per-glyph maps from second glyph to X-advance adjustment. +type pairPos1 struct { + cov map[GlyphIndex]int + sets []map[GlyphIndex]int +} + +// parsePairPos1 decodes a PairPos format 1 subtable. +func parsePairPos1(b []byte) (pairPos, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + vf1 := r.u16() + vf2 := r.u16() + setCount := int(r.u16()) + setOffs := make([]int, setCount) + for i := 0; i < setCount; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: pairpos1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets := make([]map[GlyphIndex]int, setCount) + for i, so := range setOffs { + m, err := parsePairSet(subslice(b, so), vf1, vf2) + if err != nil { + return nil, err + } + sets[i] = m + } + return &pairPos1{cov: cov, sets: sets}, nil +} + +// parsePairSet decodes a PairSet table into a second-glyph -> X-advance map. +func parsePairSet(b []byte, vf1, vf2 uint16) (map[GlyphIndex]int, error) { + r := reader{b: b} + count := int(r.u16()) + m := map[GlyphIndex]int{} + for i := 0; i < count; i++ { + second := GlyphIndex(r.u16()) + v1 := readValueRecord(&r, vf1) + readValueRecord(&r, vf2) // value2 (applies to the right glyph) is skipped + m[second] = v1.xAdvance + } + if r.err != nil { + return nil, fmt.Errorf("opentype: pairSet: %w", r.err) + } + return m, nil +} + +func (p *pairPos1) kern(left, right GlyphIndex) (int, bool) { + ci, ok := p.cov[left] + if !ok { + return 0, false + } + if v, ok := p.sets[ci][right]; ok { + return v, true + } + return 0, false +} + +func (p *pairPos1) apply(ctx *posContext, i int) bool { + j := ctx.skip.next(ctx.glyphs, i+1) + if j >= len(ctx.glyphs) { + return false + } + v, ok := p.kern(ctx.glyphs[i], ctx.glyphs[j]) + if !ok { + return false + } + ctx.positions[i].XAdvance += v + return true +} + +// pairPos2 is a pair-adjustment format 2 subtable: the first glyph must be +// covered, then both glyphs are classified and the class pair indexes a grid of +// X-advance adjustments. +type pairPos2 struct { + cov map[GlyphIndex]int + cd1, cd2 map[GlyphIndex]int + class2Count int + grid []int +} + +// parsePairPos2 decodes a PairPos format 2 subtable. +func parsePairPos2(b []byte) (pairPos, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + vf1 := r.u16() + vf2 := r.u16() + cd1Off := int(r.u16()) + cd2Off := int(r.u16()) + class1Count := int(r.u16()) + class2Count := int(r.u16()) + grid := make([]int, class1Count*class2Count) + for i := range grid { + v1 := readValueRecord(&r, vf1) + readValueRecord(&r, vf2) + grid[i] = v1.xAdvance + } + if r.err != nil { + return nil, fmt.Errorf("opentype: pairpos2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + cd1, err := parseClassDef(subslice(b, cd1Off)) + if err != nil { + return nil, err + } + cd2, err := parseClassDef(subslice(b, cd2Off)) + if err != nil { + return nil, err + } + return &pairPos2{cov: cov, cd1: cd1, cd2: cd2, class2Count: class2Count, grid: grid}, nil +} + +func (p *pairPos2) kern(left, right GlyphIndex) (int, bool) { + if _, ok := p.cov[left]; !ok { + return 0, false + } + idx := p.cd1[left]*p.class2Count + p.cd2[right] + if idx >= len(p.grid) { + return 0, false + } + return p.grid[idx], true +} + +func (p *pairPos2) apply(ctx *posContext, i int) bool { + j := ctx.skip.next(ctx.glyphs, i+1) + if j >= len(ctx.glyphs) { + return false + } + v, ok := p.kern(ctx.glyphs[i], ctx.glyphs[j]) + if !ok { + return false + } + ctx.positions[i].XAdvance += v + return true +} + +// --- Type 3: cursive attachment --------------------------------------------- + +// cursivePos joins covered glyphs by aligning one glyph's exit anchor to the +// next glyph's entry anchor. entry[i] and exit[i] are indexed by coverage index +// and may be nil (NULL anchor). +type cursivePos struct { + cov map[GlyphIndex]int + entry []*anchor + exit []*anchor +} + +// parseCursivePos decodes a CursivePos format 1 subtable. +func parseCursivePos(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + n := int(r.u16()) + entryOffs := make([]int, n) + exitOffs := make([]int, n) + for i := 0; i < n; i++ { + entryOffs[i] = int(r.u16()) + exitOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: cursivepos: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + entry := make([]*anchor, n) + exit := make([]*anchor, n) + for i := 0; i < n; i++ { + if entry[i], err = parseAnchorAt(b, entryOffs[i]); err != nil { + return nil, err + } + if exit[i], err = parseAnchorAt(b, exitOffs[i]); err != nil { + return nil, err + } + } + return &cursivePos{cov: cov, entry: entry, exit: exit}, nil +} + +func (c *cursivePos) apply(ctx *posContext, i int) bool { + ci, ok := c.cov[ctx.glyphs[i]] + if !ok { + return false + } + j := ctx.skip.next(ctx.glyphs, i+1) + if j >= len(ctx.glyphs) { + return false + } + ni, ok := c.cov[ctx.glyphs[j]] + if !ok { + return false + } + if ci >= len(c.exit) || ni >= len(c.entry) { + return false + } + ex, en := c.exit[ci], c.entry[ni] + if ex == nil || en == nil { + return false + } + // Join the exit anchor of the current glyph to the entry anchor of the next. + // Left-to-right, the following glyph is moved so its entry meets the current + // exit; right-to-left, the current glyph is moved so its exit meets the next + // glyph's entry. + if ctx.skip.rtl() { + ctx.positions[i].XOffset += en.x - ex.x + ctx.positions[i].YOffset += en.y - ex.y + } else { + ctx.positions[j].XOffset += ex.x - en.x + ctx.positions[j].YOffset += ex.y - en.y + } + return true +} + +// --- Types 4/5/6: mark attachment ------------------------------------------- + +// attachMark positions a mark glyph so its anchor coincides with the base's +// anchor, discounting the advances between the base and the mark so the mark +// (whose pen sits after the base) is pulled back onto it. +func (ctx *posContext) attachMark(base, mark int, baseAnchor, markAnchor anchor) { + dx := baseAnchor.x - markAnchor.x + dy := baseAnchor.y - markAnchor.y + for k := base; k < mark; k++ { + dx -= ctx.advance(k) + } + ctx.positions[mark].XOffset = ctx.positions[base].XOffset + dx + ctx.positions[mark].YOffset = ctx.positions[base].YOffset + dy + ctx.positions[mark].XAdvance = 0 + ctx.positions[mark].YAdvance = 0 +} + +// markBasePos is a mark-to-base (type 4) subtable: base[baseIndex][markClass] +// gives the base attachment anchor for each mark class. +type markBasePos struct { + markCov map[GlyphIndex]int + baseCov map[GlyphIndex]int + marks []markRecord + base [][]*anchor +} + +// parseMarkBasePos decodes a MarkBasePos format 1 subtable. +func parseMarkBasePos(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + markCovOff := int(r.u16()) + baseCovOff := int(r.u16()) + classCount := int(r.u16()) + markArrOff := int(r.u16()) + baseArrOff := int(r.u16()) + if r.err != nil { + return nil, fmt.Errorf("opentype: markbasepos: %w", r.err) + } + markCov, err := parseCoverage(subslice(b, markCovOff)) + if err != nil { + return nil, err + } + baseCov, err := parseCoverage(subslice(b, baseCovOff)) + if err != nil { + return nil, err + } + marks, err := parseMarkArray(subslice(b, markArrOff)) + if err != nil { + return nil, err + } + base, err := parseAnchorMatrix(subslice(b, baseArrOff), classCount) + if err != nil { + return nil, err + } + return &markBasePos{markCov: markCov, baseCov: baseCov, marks: marks, base: base}, nil +} + +func (m *markBasePos) apply(ctx *posContext, i int) bool { + mi, ok := m.markCov[ctx.glyphs[i]] + if !ok { + return false + } + if mi >= len(m.marks) { + return false + } + mr := m.marks[mi] + for b := i - 1; b >= 0; b-- { + // A base glyph is the nearest preceding non-skipped, non-mark glyph: skip + // glyphs the lookup ignores and any GDEF mark (a mark cannot be a base). + if ctx.skip.skip(ctx.glyphs[b]) || ctx.skip.isMark(ctx.glyphs[b]) { + continue + } + bi, ok := m.baseCov[ctx.glyphs[b]] + if !ok { + continue + } + if bi >= len(m.base) || int(mr.class) >= len(m.base[bi]) { + return false + } + ba := m.base[bi][mr.class] + if ba == nil { + return false + } + ctx.attachMark(b, i, *ba, mr.anch) + return true + } + return false +} + +// markLigPos is a mark-to-ligature (type 5) subtable: ligs[ligIndex][component] +// [markClass] gives the attachment anchor. Component selection (which needs the +// GSUB ligature-component tracking the caller does not provide) is simplified +// to the first component. +type markLigPos struct { + markCov map[GlyphIndex]int + ligCov map[GlyphIndex]int + marks []markRecord + ligs [][][]*anchor +} + +// parseMarkLigPos decodes a MarkLigPos format 1 subtable. +func parseMarkLigPos(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + markCovOff := int(r.u16()) + ligCovOff := int(r.u16()) + classCount := int(r.u16()) + markArrOff := int(r.u16()) + ligArrOff := int(r.u16()) + if r.err != nil { + return nil, fmt.Errorf("opentype: markligpos: %w", r.err) + } + markCov, err := parseCoverage(subslice(b, markCovOff)) + if err != nil { + return nil, err + } + ligCov, err := parseCoverage(subslice(b, ligCovOff)) + if err != nil { + return nil, err + } + marks, err := parseMarkArray(subslice(b, markArrOff)) + if err != nil { + return nil, err + } + ligs, err := parseLigatureArray(subslice(b, ligArrOff), classCount) + if err != nil { + return nil, err + } + return &markLigPos{markCov: markCov, ligCov: ligCov, marks: marks, ligs: ligs}, nil +} + +// parseLigatureArray decodes a LigatureArray table: a ligatureCount uint16 then +// that many LigatureAttach offsets, each an anchor matrix of componentCount +// rows and cols (markClassCount) columns. +func parseLigatureArray(b []byte, cols int) ([][][]*anchor, error) { + r := reader{b: b} + n := int(r.u16()) + offs := make([]int, n) + for i := 0; i < n; i++ { + offs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: ligatureArray: %w", r.err) + } + out := make([][][]*anchor, n) + for i, off := range offs { + la, err := parseAnchorMatrix(subslice(b, off), cols) + if err != nil { + return nil, err + } + out[i] = la + } + return out, nil +} + +func (m *markLigPos) apply(ctx *posContext, i int) bool { + mi, ok := m.markCov[ctx.glyphs[i]] + if !ok { + return false + } + if mi >= len(m.marks) { + return false + } + mr := m.marks[mi] + for b := i - 1; b >= 0; b-- { + // The base ligature is the nearest preceding non-skipped, non-mark glyph. + if ctx.skip.skip(ctx.glyphs[b]) || ctx.skip.isMark(ctx.glyphs[b]) { + continue + } + li, ok := m.ligCov[ctx.glyphs[b]] + if !ok { + continue + } + if li >= len(m.ligs) || len(m.ligs[li]) == 0 { + return false + } + comp := m.ligs[li][0] // first component (documented simplification) + if int(mr.class) >= len(comp) { + return false + } + la := comp[mr.class] + if la == nil { + return false + } + ctx.attachMark(b, i, *la, mr.anch) + return true + } + return false +} + +// markMarkPos is a mark-to-mark (type 6) subtable: mark2[mark2Index][markClass] +// gives the base-mark attachment anchor. +type markMarkPos struct { + mark1Cov map[GlyphIndex]int + mark2Cov map[GlyphIndex]int + marks []markRecord + mark2 [][]*anchor +} + +// parseMarkMarkPos decodes a MarkMarkPos format 1 subtable. +func parseMarkMarkPos(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + mark1CovOff := int(r.u16()) + mark2CovOff := int(r.u16()) + classCount := int(r.u16()) + mark1ArrOff := int(r.u16()) + mark2ArrOff := int(r.u16()) + if r.err != nil { + return nil, fmt.Errorf("opentype: markmarkpos: %w", r.err) + } + mark1Cov, err := parseCoverage(subslice(b, mark1CovOff)) + if err != nil { + return nil, err + } + mark2Cov, err := parseCoverage(subslice(b, mark2CovOff)) + if err != nil { + return nil, err + } + marks, err := parseMarkArray(subslice(b, mark1ArrOff)) + if err != nil { + return nil, err + } + mark2, err := parseAnchorMatrix(subslice(b, mark2ArrOff), classCount) + if err != nil { + return nil, err + } + return &markMarkPos{mark1Cov: mark1Cov, mark2Cov: mark2Cov, marks: marks, mark2: mark2}, nil +} + +func (m *markMarkPos) apply(ctx *posContext, i int) bool { + mi, ok := m.mark1Cov[ctx.glyphs[i]] + if !ok { + return false + } + if mi >= len(m.marks) { + return false + } + mr := m.marks[mi] + for b := i - 1; b >= 0; b-- { + // The base mark is the nearest preceding non-skipped glyph (it is itself a + // mark, so marks are not skipped here — only the lookup's own ignores). + if ctx.skip.skip(ctx.glyphs[b]) { + continue + } + bi, ok := m.mark2Cov[ctx.glyphs[b]] + if !ok { + continue + } + if bi >= len(m.mark2) || int(mr.class) >= len(m.mark2[bi]) { + return false + } + ba := m.mark2[bi][mr.class] + if ba == nil { + return false + } + ctx.attachMark(b, i, *ba, mr.anch) + return true + } + return false +} + +// --- Types 7/8: contextual and chaining positioning ------------------------- + +// posLookupRecord applies the lookup at lookupIndex to the input glyph at +// seqIndex within a matched context. +type posLookupRecord struct { + seqIndex uint16 + lookupIndex uint16 +} + +// readPosLookupRecords reads n PosLookupRecords. +func readPosLookupRecords(r *reader, n int) []posLookupRecord { + recs := make([]posLookupRecord, n) + for i := range recs { + recs[i].seqIndex = r.u16() + recs[i].lookupIndex = r.u16() + } + return recs +} + +// glyphPredsG builds glyph-id-equality predicates from a GlyphIndex sequence +// (the in-memory form of GPOS input, backtrack and lookahead sequences). +func glyphPredsG(seq []GlyphIndex) []glyphPred { + preds := make([]glyphPred, len(seq)) + for k := range seq { + v := seq[k] + preds[k] = func(g GlyphIndex) bool { return g == v } + } + return preds +} + +// classPredsG builds class-equality predicates from a GlyphIndex class sequence +// (stored with the on-disk class values of a Pos(Chain)ClassRule) and a +// ClassDef map; glyphs absent from cd fall in class 0. +func classPredsG(seq []GlyphIndex, cd map[GlyphIndex]int) []glyphPred { + preds := make([]glyphPred, len(seq)) + for k := range seq { + v := int(seq[k]) + preds[k] = func(g GlyphIndex) bool { return classOf(cd, g) == v } + } + return preds +} + +// posRule is one contextual (type 7 format 1) rule: an input sequence (from the +// second glyph) and the lookup records to run when it matches. +type posRule struct { + input []GlyphIndex + recs []posLookupRecord +} + +// contextPos1 is a contextual positioning format 1 subtable. +type contextPos1 struct { + cov map[GlyphIndex]int + sets [][]posRule +} + +// contextPos2 is a contextual positioning format 2 subtable (class based): the +// first glyph must be covered, then the input run is matched by glyph class. +// sets is indexed by the first glyph's class; a nil set is empty (a NULL +// PosClassSet offset, common for class 0). +type contextPos2 struct { + cov map[GlyphIndex]int + classDef map[GlyphIndex]int + sets [][]posRule +} + +// contextPos3 is a contextual positioning format 3 subtable: a per-position +// coverage sequence and the lookup records to run on a full match. +type contextPos3 struct { + covs []map[GlyphIndex]int + recs []posLookupRecord +} + +// parseContextPos decodes a contextual positioning subtable (format 1, 2 or 3). +func parseContextPos(b []byte) (posSubtable, error) { + r := reader{b: b} + format := r.u16() + if r.err != nil { + return nil, fmt.Errorf("opentype: contextpos: %w", r.err) + } + switch format { + case 1: + return parseContextPos1(b) + case 2: + return parseContextPos2(b) + case 3: + return parseContextPos3(b) + default: + return nil, fmt.Errorf("opentype: contextpos format %d", format) + } +} + +// parseContextPos2 decodes a contextual positioning format 2 subtable. +func parseContextPos2(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + classOff := int(r.u16()) + n := int(r.u16()) + setOffs := make([]int, n) + for i := 0; i < n; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: contextpos2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + cd, err := parseClassDef(subslice(b, classOff)) + if err != nil { + return nil, err + } + sets := make([][]posRule, n) + for i, so := range setOffs { + if so == 0 { + continue // NULL PosClassSet: this class triggers no rule + } + rs, err := parsePosRuleSet(subslice(b, so)) + if err != nil { + return nil, err + } + sets[i] = rs + } + return &contextPos2{cov: cov, classDef: cd, sets: sets}, nil +} + +func (c *contextPos2) apply(ctx *posContext, i int) bool { + if _, ok := c.cov[ctx.glyphs[i]]; !ok { + return false + } + cls := classOf(c.classDef, ctx.glyphs[i]) + if cls >= len(c.sets) { + return false + } + for _, rule := range c.sets[cls] { + matched, _, ok := ctx.skip.matchForward(ctx.glyphs, i+1, classPredsG(rule.input, c.classDef)) + if !ok { + continue + } + ctx.applyRecords(rule.recs, append([]int{i}, matched...)) + return true + } + return false +} + +// parseContextPos1 decodes a contextual positioning format 1 subtable. +func parseContextPos1(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + n := int(r.u16()) + setOffs := make([]int, n) + for i := 0; i < n; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: contextpos1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets := make([][]posRule, n) + for i, so := range setOffs { + rs, err := parsePosRuleSet(subslice(b, so)) + if err != nil { + return nil, err + } + sets[i] = rs + } + return &contextPos1{cov: cov, sets: sets}, nil +} + +// parsePosRuleSet decodes a PosRuleSet table (a list of PosRule offsets). +func parsePosRuleSet(b []byte) ([]posRule, error) { + r := reader{b: b} + n := int(r.u16()) + offs := make([]int, n) + for i := 0; i < n; i++ { + offs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: posRuleSet: %w", r.err) + } + rules := make([]posRule, n) + for i, off := range offs { + rule, err := parsePosRule(subslice(b, off)) + if err != nil { + return nil, err + } + rules[i] = rule + } + return rules, nil +} + +// parsePosRule decodes one PosRule table. +func parsePosRule(b []byte) (posRule, error) { + r := reader{b: b} + glyphCount := int(r.u16()) + lookupCount := int(r.u16()) + input := make([]GlyphIndex, 0, glyphCount) + for k := 1; k < glyphCount; k++ { + input = append(input, GlyphIndex(r.u16())) + } + recs := readPosLookupRecords(&r, lookupCount) + if r.err != nil { + return posRule{}, fmt.Errorf("opentype: posRule: %w", r.err) + } + return posRule{input: input, recs: recs}, nil +} + +func (c *contextPos1) apply(ctx *posContext, i int) bool { + ci, ok := c.cov[ctx.glyphs[i]] + if !ok { + return false + } + if ci >= len(c.sets) { + return false + } + for _, rule := range c.sets[ci] { + matched, _, ok := ctx.skip.matchForward(ctx.glyphs, i+1, glyphPredsG(rule.input)) + if !ok { + continue + } + ctx.applyRecords(rule.recs, append([]int{i}, matched...)) + return true + } + return false +} + +// parseContextPos3 decodes a contextual positioning format 3 subtable. +func parseContextPos3(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + glyphCount := int(r.u16()) + lookupCount := int(r.u16()) + covOffs := make([]int, glyphCount) + for i := 0; i < glyphCount; i++ { + covOffs[i] = int(r.u16()) + } + recs := readPosLookupRecords(&r, lookupCount) + if r.err != nil { + return nil, fmt.Errorf("opentype: contextpos3: %w", r.err) + } + covs, err := parsePosCoverageList(b, covOffs) + if err != nil { + return nil, err + } + return &contextPos3{covs: covs, recs: recs}, nil +} + +func (c *contextPos3) apply(ctx *posContext, i int) bool { + matched, _, ok := ctx.skip.matchContext(ctx.glyphs, i, c.covs) + if !ok { + return false + } + ctx.applyRecords(c.recs, matched) + return true +} + +// parsePosCoverageList decodes a slice of Coverage tables at the given offsets from +// the start of b. +func parsePosCoverageList(b []byte, offs []int) ([]map[GlyphIndex]int, error) { + covs := make([]map[GlyphIndex]int, len(offs)) + for i, off := range offs { + cov, err := parseCoverage(subslice(b, off)) + if err != nil { + return nil, err + } + covs[i] = cov + } + return covs, nil +} + +// posChainRule is one chaining contextual (type 8 format 1) rule. +type posChainRule struct { + back []GlyphIndex + input []GlyphIndex + ahead []GlyphIndex + recs []posLookupRecord +} + +// chainPos1 is a chaining contextual positioning format 1 subtable. +type chainPos1 struct { + cov map[GlyphIndex]int + sets [][]posChainRule +} + +// chainPos2 is a chaining contextual positioning format 2 subtable (class +// based): three ClassDefs classify the backtrack, input and lookahead runs, and +// rules match by class sequence. sets is indexed by the first glyph's input +// class; a nil set is empty (a NULL ChainPosClassSet offset). +type chainPos2 struct { + cov map[GlyphIndex]int + backtrack map[GlyphIndex]int + input map[GlyphIndex]int + lookahead map[GlyphIndex]int + sets [][]posChainRule +} + +// chainPos3 is a chaining contextual positioning format 3 subtable. +type chainPos3 struct { + back []map[GlyphIndex]int + input []map[GlyphIndex]int + ahead []map[GlyphIndex]int + recs []posLookupRecord +} + +// parseChainPos decodes a chaining contextual positioning subtable (format 1, 2 +// or 3). +func parseChainPos(b []byte) (posSubtable, error) { + r := reader{b: b} + format := r.u16() + if r.err != nil { + return nil, fmt.Errorf("opentype: chainpos: %w", r.err) + } + switch format { + case 1: + return parseChainPos1(b) + case 2: + return parseChainPos2(b) + case 3: + return parseChainPos3(b) + default: + return nil, fmt.Errorf("opentype: chainpos format %d", format) + } +} + +// parseChainPos2 decodes a chaining contextual positioning format 2 subtable. +func parseChainPos2(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + btOff := int(r.u16()) + inOff := int(r.u16()) + laOff := int(r.u16()) + n := int(r.u16()) + setOffs := make([]int, n) + for i := 0; i < n; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: chainpos2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + btCD, err := parseClassDef(subslice(b, btOff)) + if err != nil { + return nil, err + } + inCD, err := parseClassDef(subslice(b, inOff)) + if err != nil { + return nil, err + } + laCD, err := parseClassDef(subslice(b, laOff)) + if err != nil { + return nil, err + } + sets := make([][]posChainRule, n) + for i, so := range setOffs { + if so == 0 { + continue // NULL ChainPosClassSet: this class triggers no rule + } + rs, err := parsePosChainRuleSet(subslice(b, so)) + if err != nil { + return nil, err + } + sets[i] = rs + } + return &chainPos2{cov: cov, backtrack: btCD, input: inCD, lookahead: laCD, sets: sets}, nil +} + +func (c *chainPos2) apply(ctx *posContext, i int) bool { + if _, ok := c.cov[ctx.glyphs[i]]; !ok { + return false + } + cls := classOf(c.input, ctx.glyphs[i]) + if cls >= len(c.sets) { + return false + } + for _, rule := range c.sets[cls] { + matched, end, ok := ctx.skip.matchForward(ctx.glyphs, i+1, classPredsG(rule.input, c.input)) + if !ok || + !ctx.skip.matchBackward(ctx.glyphs, i-1, classPredsG(rule.back, c.backtrack)) { + continue + } + if _, _, ok := ctx.skip.matchForward(ctx.glyphs, end, classPredsG(rule.ahead, c.lookahead)); !ok { + continue + } + ctx.applyRecords(rule.recs, append([]int{i}, matched...)) + return true + } + return false +} + +// parseChainPos1 decodes a chaining contextual positioning format 1 subtable. +func parseChainPos1(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + covOff := int(r.u16()) + n := int(r.u16()) + setOffs := make([]int, n) + for i := 0; i < n; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: chainpos1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets := make([][]posChainRule, n) + for i, so := range setOffs { + rs, err := parsePosChainRuleSet(subslice(b, so)) + if err != nil { + return nil, err + } + sets[i] = rs + } + return &chainPos1{cov: cov, sets: sets}, nil +} + +// parsePosChainRuleSet decodes a ChainPosRuleSet table. +func parsePosChainRuleSet(b []byte) ([]posChainRule, error) { + r := reader{b: b} + n := int(r.u16()) + offs := make([]int, n) + for i := 0; i < n; i++ { + offs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: chainRuleSet: %w", r.err) + } + rules := make([]posChainRule, n) + for i, off := range offs { + rule, err := parsePosChainRule(subslice(b, off)) + if err != nil { + return nil, err + } + rules[i] = rule + } + return rules, nil +} + +// parsePosChainRule decodes one ChainPosRule table. +func parsePosChainRule(b []byte) (posChainRule, error) { + r := reader{b: b} + bn := int(r.u16()) + back := make([]GlyphIndex, bn) + for k := 0; k < bn; k++ { + back[k] = GlyphIndex(r.u16()) + } + in := int(r.u16()) + input := make([]GlyphIndex, 0, in) + for k := 1; k < in; k++ { + input = append(input, GlyphIndex(r.u16())) + } + an := int(r.u16()) + ahead := make([]GlyphIndex, an) + for k := 0; k < an; k++ { + ahead[k] = GlyphIndex(r.u16()) + } + ln := int(r.u16()) + recs := readPosLookupRecords(&r, ln) + if r.err != nil { + return posChainRule{}, fmt.Errorf("opentype: posChainRule: %w", r.err) + } + return posChainRule{back: back, input: input, ahead: ahead, recs: recs}, nil +} + +func (c *chainPos1) apply(ctx *posContext, i int) bool { + ci, ok := c.cov[ctx.glyphs[i]] + if !ok { + return false + } + if ci >= len(c.sets) { + return false + } + for _, rule := range c.sets[ci] { + matched, end, ok := ctx.skip.matchForward(ctx.glyphs, i+1, glyphPredsG(rule.input)) + if !ok || + !ctx.skip.matchBackward(ctx.glyphs, i-1, glyphPredsG(rule.back)) { + continue + } + if _, _, ok := ctx.skip.matchForward(ctx.glyphs, end, glyphPredsG(rule.ahead)); !ok { + continue + } + ctx.applyRecords(rule.recs, append([]int{i}, matched...)) + return true + } + return false +} + +// parseChainPos3 decodes a chaining contextual positioning format 3 subtable. +func parseChainPos3(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat + bn := int(r.u16()) + backOffs := readOffsets(&r, bn) + in := int(r.u16()) + inputOffs := readOffsets(&r, in) + an := int(r.u16()) + aheadOffs := readOffsets(&r, an) + ln := int(r.u16()) + recs := readPosLookupRecords(&r, ln) + if r.err != nil { + return nil, fmt.Errorf("opentype: chainpos3: %w", r.err) + } + back, err := parsePosCoverageList(b, backOffs) + if err != nil { + return nil, err + } + input, err := parsePosCoverageList(b, inputOffs) + if err != nil { + return nil, err + } + ahead, err := parsePosCoverageList(b, aheadOffs) + if err != nil { + return nil, err + } + return &chainPos3{back: back, input: input, ahead: ahead, recs: recs}, nil +} + +// readOffsets reads n uint16 offsets. +func readOffsets(r *reader, n int) []int { + offs := make([]int, n) + for i := range offs { + offs[i] = int(r.u16()) + } + return offs +} + +func (c *chainPos3) apply(ctx *posContext, i int) bool { + matched, end, ok := ctx.skip.matchContext(ctx.glyphs, i, c.input) + if !ok || + !ctx.skip.matchBackward(ctx.glyphs, i-1, covPreds(c.back)) { + return false + } + if _, _, ok := ctx.skip.matchForward(ctx.glyphs, end, covPreds(c.ahead)); !ok { + return false + } + ctx.applyRecords(c.recs, matched) + return true +} + +// --- Type 9: extension positioning ------------------------------------------ + +// parseExtensionPos decodes an ExtensionPos format 1 subtable, following the +// indirection to the real subtable of extensionLookupType. +func parseExtensionPos(b []byte) (posSubtable, error) { + r := reader{b: b} + r.skip(2) // posFormat (1) + extType := r.u16() + off := int(r.u32()) + if r.err != nil { + return nil, fmt.Errorf("opentype: extensionpos: %w", r.err) + } + if extType == 9 { + return nil, fmt.Errorf("opentype: nested extension positioning") + } + return parseGPOSSubtable(extType, subslice(b, off)) +} + +// --- Application over a glyph run ------------------------------------------- + +// posContext carries the run being positioned and the accumulating adjustments +// while a lookup (and any lookups it nests) is applied. +type posContext struct { + g *gpos + glyphs []GlyphIndex + positions []GlyphPosition + advances []int + skip skipper // skipper of the lookup currently being applied +} + +// advance returns the effective X advance of glyph k (its base advance, when +// supplied, plus any adjustment applied so far). +func (ctx *posContext) advance(k int) int { + a := ctx.positions[k].XAdvance + if k < len(ctx.advances) { + a += ctx.advances[k] + } + return a +} + +// applyRecords runs a context's PosLookupRecords. positions holds the absolute +// run indices of the matched input glyphs (skipped glyphs excluded), so a +// record's seqIndex selects the position it acts on; a record whose seqIndex +// lies past the matched input is a no-op. +func (ctx *posContext) applyRecords(recs []posLookupRecord, positions []int) { + for _, rec := range recs { + si := int(rec.seqIndex) + if si >= len(positions) { + continue + } + ctx.g.applyLookupAt(int(rec.lookupIndex), ctx, positions[si]) + } +} + +// applyLookup applies one lookup left-to-right across the whole run, taking the +// first subtable that matches at each position. A glyph the lookup's flags +// ignore is not a starting position and is skipped. +func (p *gpos) applyLookup(li int, ctx *posContext) { + if li < 0 || li >= len(p.lookups) { + return + } + lk := p.lookups[li] + ctx.skip = p.skipperFor(lk) + for i := 0; i < len(ctx.glyphs); i++ { + if ctx.skip.skip(ctx.glyphs[i]) { + continue + } + for _, st := range lk.subtables { + if st.apply(ctx, i) { + break + } + } + } +} + +// applyLookupAt applies one lookup at a single run position, taking the first +// matching subtable; it reports whether any subtable matched. The nested lookup +// matches under its own lookupFlag, so the context's skipper is swapped for the +// duration and restored afterwards. +func (p *gpos) applyLookupAt(li int, ctx *posContext, i int) bool { + if li < 0 || li >= len(p.lookups) { + return false + } + if i < 0 || i >= len(ctx.glyphs) { + return false + } + saved := ctx.skip + ctx.skip = p.skipperFor(p.lookups[li]) + matched := false + for _, st := range p.lookups[li].subtables { + if st.apply(ctx, i) { + matched = true + break + } + } + ctx.skip = saved + return matched +} + +// position applies the GPOS lookups activated by the given feature tags over a +// glyph run and returns one adjustment per glyph. advances gives each glyph's +// base horizontal advance in font units (used to pull marks back onto their +// base); it may be nil, treated as all-zero. The returned slice has the same +// length as glyphs; an empty run or no matching lookups yields all-zero +// adjustments. +func (p *gpos) position(glyphs []GlyphIndex, advances []int, features ...string) []GlyphPosition { + positions := make([]GlyphPosition, len(glyphs)) + if len(glyphs) == 0 { + return positions + } + want := make(map[string]bool, len(features)) + for _, f := range features { + want[f] = true + } + ctx := &posContext{g: p, glyphs: glyphs, positions: positions, advances: advances} + for _, li := range p.selectLookupsDefault(want) { + p.applyLookup(li, ctx) + } + return positions +} + +// --- Feature/script selection ------------------------------------------------ + +// featureLookups accumulates, into out (de-duplicated via seen), the lookup +// indices activated by the wanted feature tags of one LangSys, including its +// required feature. +func (h *layoutHeader) featureLookups(ls *langSys, want map[string]bool, seen map[int]bool, 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) + } +} + +// selectLookupsDefault resolves the lookups for the wanted features using the +// default (script-agnostic) resolution real text runs use: it first tries the +// DFLT script's default LangSys, then falls back to unioning the wanted +// features across every script's default LangSys. The fallback is what makes a +// font that registers "kern"/"mark"/"mkmk" only under a real script (such as +// latn on Source Serif, or arab) resolve when a bare DFLT lookup was empty. +func (h *layoutHeader) selectLookupsDefault(want map[string]bool) []int { + if out := h.selectLookups("DFLT", "", want); len(out) > 0 { + return out + } + seen := map[int]bool{} + var out []int + for i := range h.scripts { + if ls := h.scripts[i].defaultLang; ls != nil { + h.featureLookups(ls, want, seen, &out) + } + } + sort.Ints(out) + return out +} + +// Kern returns the X-advance adjustment GPOS applies between left and right via +// the "kern" feature, or 0 when no pair-adjustment lookup covers the pair. The +// kern feature is resolved with the default script-agnostic fallback, so fonts +// that file kerning under a real script (not DFLT) still resolve. +func (p *gpos) Kern(left, right GlyphIndex) int { + idxs := p.selectLookupsDefault(map[string]bool{"kern": true}) + for _, li := range idxs { + if li >= len(p.lookups) { + continue + } + for _, st := range p.lookups[li].subtables { + pp, ok := st.(pairPos) + if !ok { + continue + } + if v, ok := pp.kern(left, right); ok { + return v + } + } + } + return 0 +} + +// Kerner combines GPOS and the legacy kern table: GPOS is preferred and the +// kern table is a fallback used when GPOS yields no adjustment. Either source +// may be nil. +type Kerner struct { + gpos *gpos + kern *kern +} + +// Kerning returns the X-advance adjustment between left and right, preferring a +// non-zero GPOS value and otherwise falling back to the legacy kern table. +func (kn *Kerner) Kerning(left, right GlyphIndex) int { + if kn.gpos != nil { + if v := kn.gpos.Kern(left, right); v != 0 { + return v + } + } + if kn.kern != nil { + return kn.kern.Pair(left, right) + } + return 0 +} diff --git a/vendor/github.com/go-opentype/opentype/gsub.go b/vendor/github.com/go-opentype/opentype/gsub.go new file mode 100644 index 0000000..c9a3da4 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/gsub.go @@ -0,0 +1,1197 @@ +// 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 and applies the GSUB (Glyph Substitution) table. All eight +// substitution lookup types defined by OpenType are implemented: +// +// - Type 1, single substitution (formats 1 and 2): replace one glyph with +// another, used by features such as small caps or stylistic swaps. +// - Type 2, multiple substitution (format 1): replace one glyph with a +// sequence of glyphs (one -> many), e.g. decomposing a ligature. +// - Type 3, alternate substitution (format 1): choose an alternate glyph; this +// implementation exposes the first (default) alternate. +// - Type 4, ligature substitution (format 1): replace a sequence of glyphs +// with a single ligature glyph, used by "liga" (for example f+i -> fi). +// - Type 5, contextual substitution (formats 1/2/3): apply nested lookups when +// the glyph run matches a glyph-, class- or coverage-based context. +// - Type 6, chaining-contextual substitution (formats 1/2/3): like type 5 but +// with backtrack and lookahead context, the backbone of Arabic +// init/medi/fina contextual forms and Indic reordering. +// - Type 7, extension substitution (format 1): a 32-bit indirection to a +// subtable of another type, used to reach past the 16-bit offset limit. +// - Type 8, reverse chaining single substitution (format 1): a single +// substitution with backtrack and lookahead context, applied back-to-front. +// +// Contextual and chaining lookups (types 5 and 6) drive nested lookups through +// SequenceLookupRecords; that recursion is bounded by maxGSUBNest. A lookup of +// an undefined type (there is none above 8) parses to a no-op and is skipped. + +// maxGSUBNest bounds the recursion depth of nested (contextual) lookup +// application, guarding against a font whose lookups reference one another in a +// cycle. +const maxGSUBNest = 64 + +// gsub is a decoded GSUB table. +type gsub struct { + layoutHeader + lookups []gsubLookup + gdef *gdefTable // font GDEF (nil when absent); drives glyph skipping +} + +// gsubLookup is one decoded GSUB Lookup: its (supported) subtables. reverse is +// set when the lookup performs reverse chaining single substitution (type 8), +// which is applied to the glyph run back-to-front. flag is the lookup's +// lookupFlag and markSet its mark-filtering-set index; together with the font's +// GDEF table they drive which glyphs the lookup skips while matching. +type gsubLookup struct { + subtables []gsubSubtable + reverse bool + flag uint16 + markSet uint16 +} + +// gsubApplier carries the state threaded through a GSUB pass: the full lookup +// list (so contextual lookups can recurse into nested lookups) and the current +// recursion depth. clusters, when non-nil, is a parallel per-glyph slice kept +// in lock-step with the run as it is rewritten: the length-changing subtables +// (multiple and ligature substitution) splice it exactly as they splice the +// glyphs, so each output glyph records the input position it derives from. It +// is nil for plain (untracked) application, which leaves those subtables' fast +// path unchanged. +type gsubApplier struct { + lookups []gsubLookup + depth int + clusters []int + gdef *gdefTable // font GDEF (nil when absent); drives glyph skipping + skip skipper // skipper of the lookup currently being applied +} + +// skipperFor builds the glyph skipper for lookup lk from the applier's GDEF +// table and the lookup's flag and mark-filtering set. +func (a *gsubApplier) skipperFor(lk gsubLookup) skipper { + return skipper{gdef: a.gdef, flag: lk.flag, markFilterSet: lk.markSet} +} + +// gsubSubtable attempts a substitution at position i of a glyph run. On success +// it returns the resulting run, the index to continue from, and true; otherwise +// it returns the run unchanged and false. Contextual subtables use the applier +// to run their nested lookups. +type gsubSubtable interface { + sub(a *gsubApplier, glyphs []GlyphIndex, i int) (out []GlyphIndex, next int, ok bool) +} + +// seqLookupRecord binds a position within a matched sequence (seqIndex, counted +// from the first input glyph) to the lookup applied there. +type seqLookupRecord struct { + seqIndex uint16 + lookupIndex uint16 +} + +// readU16s reads n consecutive big-endian uint16 values; a non-positive n +// yields nil (used where a count field may underflow, e.g. inputCount-1). +func readU16s(r *reader, n int) []uint16 { + if n <= 0 { + return nil + } + out := make([]uint16, n) + for i := range out { + out[i] = r.u16() + } + return out +} + +// parseSeqLookupRecords reads n SequenceLookupRecords. +func parseSeqLookupRecords(r *reader, n int) []seqLookupRecord { + if n <= 0 { + return nil + } + recs := make([]seqLookupRecord, n) + for i := range recs { + recs[i].seqIndex = r.u16() + recs[i].lookupIndex = r.u16() + } + return recs +} + +// parseCoverageList decodes the Coverage tables at the given offsets (measured +// from the start of b) into a slice of glyph->coverage-index maps. +func parseCoverageList(b []byte, offs []uint16) ([]map[GlyphIndex]int, error) { + out := make([]map[GlyphIndex]int, len(offs)) + for i, off := range offs { + cov, err := parseCoverage(subslice(b, int(off))) + if err != nil { + return nil, err + } + out[i] = cov + } + return out, nil +} + +// parseGSUB decodes a GSUB table. +func parseGSUB(b []byte) (*gsub, error) { + hdr, raw, err := parseLayoutCommon(b) + if err != nil { + return nil, err + } + lookups := make([]gsubLookup, len(raw)) + for i, lk := range raw { + gl, err := parseGSUBLookup(lk) + if err != nil { + return nil, err + } + lookups[i] = gl + } + return &gsub{layoutHeader: hdr, lookups: lookups}, nil +} + +// parseGSUBLookup decodes one Lookup table, keeping only subtables of a +// supported type and flagging reverse-chaining lookups. +func parseGSUBLookup(b []byte) (gsubLookup, error) { + r := reader{b: b} + lookupType := r.u16() + flag := r.u16() + subCount := int(r.u16()) + offs := make([]int, subCount) + for i := 0; i < subCount; i++ { + offs[i] = int(r.u16()) + } + var markSet uint16 + if flag&flagUseMarkFilteringSet != 0 { + markSet = r.u16() // markFilteringSet follows the subtable offsets + } + if r.err != nil { + return gsubLookup{}, fmt.Errorf("opentype: gsub lookup: %w", r.err) + } + var subs []gsubSubtable + for _, off := range offs { + st, err := parseGSUBSubtable(lookupType, subslice(b, off)) + if err != nil { + return gsubLookup{}, err + } + if st != nil { + subs = append(subs, st) + } + } + gl := gsubLookup{subtables: subs, flag: flag, markSet: markSet} + for _, st := range subs { + if _, ok := st.(*gsubReverseChain1); ok { + gl.reverse = true + break + } + } + return gl, nil +} + +// parseGSUBSubtable decodes one subtable of the given lookup type; an undefined +// type yields a nil subtable that the caller drops. +func parseGSUBSubtable(lookupType uint16, b []byte) (gsubSubtable, error) { + switch lookupType { + case 1: + return parseGSUBSingle(b) + case 2: + return parseGSUBMultiple(b) + case 3: + return parseGSUBAlternate(b) + case 4: + return parseGSUBLigature(b) + case 5: + return parseGSUBContext(b) + case 6: + return parseGSUBChain(b) + case 7: + return parseGSUBExtension(b) + case 8: + return parseGSUBReverse(b) + default: + return nil, nil // no lookup type above 8 is defined (documented) + } +} + +// --- Type 1: single substitution -------------------------------------------- + +// gsubSingle1 is a single-substitution format 1 subtable: covered glyphs are +// shifted by a constant delta. +type gsubSingle1 struct { + cov map[GlyphIndex]int + delta int16 +} + +// gsubSingle2 is a single-substitution format 2 subtable: covered glyphs are +// replaced by the glyph at their coverage index. +type gsubSingle2 struct { + cov map[GlyphIndex]int + subst []GlyphIndex +} + +// parseGSUBSingle decodes a single-substitution subtable (format 1 or 2). +func parseGSUBSingle(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + covOff := int(r.u16()) + switch format { + case 1: + delta := r.i16() + if r.err != nil { + return nil, fmt.Errorf("opentype: single subst 1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + return &gsubSingle1{cov: cov, delta: delta}, nil + case 2: + n := int(r.u16()) + subst := make([]GlyphIndex, n) + for i := 0; i < n; i++ { + subst[i] = GlyphIndex(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: single subst 2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + return &gsubSingle2{cov: cov, subst: subst}, nil + default: + return nil, fmt.Errorf("opentype: single subst format %d", format) + } +} + +func (s *gsubSingle1) sub(_ *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + if _, ok := s.cov[g[i]]; !ok { + return g, i, false + } + g[i] = GlyphIndex(uint16(g[i]) + uint16(s.delta)) + return g, i + 1, true +} + +func (s *gsubSingle2) sub(_ *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + idx, ok := s.cov[g[i]] + if !ok { + return g, i, false + } + g[i] = s.subst[idx] + return g, i + 1, true +} + +// --- Type 2: multiple substitution ------------------------------------------ + +// gsubMultiple1 replaces a covered glyph with the sequence at its coverage +// index (one -> many; an empty sequence deletes the glyph). +type gsubMultiple1 struct { + cov map[GlyphIndex]int + seqs [][]GlyphIndex +} + +// parseSequenceTable decodes a Sequence/AlternateSet table: a count followed by +// that many glyph ids. +func parseSequenceTable(b []byte) ([]GlyphIndex, error) { + r := reader{b: b} + n := int(r.u16()) + out := make([]GlyphIndex, n) + for i := 0; i < n; i++ { + out[i] = GlyphIndex(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: sequence: %w", r.err) + } + return out, nil +} + +// parseGSUBMultiple decodes a multiple-substitution (format 1) subtable. +func parseGSUBMultiple(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + if format != 1 { + return nil, fmt.Errorf("opentype: multiple subst format %d", format) + } + covOff := int(r.u16()) + n := int(r.u16()) + seqOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: multiple subst: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + seqs := make([][]GlyphIndex, n) + for i, off := range seqOffs { + s, err := parseSequenceTable(subslice(b, int(off))) + if err != nil { + return nil, err + } + seqs[i] = s + } + return &gsubMultiple1{cov: cov, seqs: seqs}, nil +} + +func (s *gsubMultiple1) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + idx, ok := s.cov[g[i]] + if !ok { + return g, i, false + } + seq := s.seqs[idx] + out := make([]GlyphIndex, 0, len(g)-1+len(seq)) + out = append(out, g[:i]...) + out = append(out, seq...) + out = append(out, g[i+1:]...) + a.spliceClusters(i, 1, len(seq)) + return out, i + len(seq), true +} + +// --- Type 3: alternate substitution ----------------------------------------- + +// gsubAlternate1 replaces a covered glyph with the first (default) alternate of +// its AlternateSet. +type gsubAlternate1 struct { + cov map[GlyphIndex]int + sets [][]GlyphIndex +} + +// parseGSUBAlternate decodes an alternate-substitution (format 1) subtable; its +// binary layout matches multiple substitution (Coverage + a list of sets). +func parseGSUBAlternate(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + if format != 1 { + return nil, fmt.Errorf("opentype: alternate subst format %d", format) + } + covOff := int(r.u16()) + n := int(r.u16()) + setOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: alternate subst: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets := make([][]GlyphIndex, n) + for i, off := range setOffs { + s, err := parseSequenceTable(subslice(b, int(off))) + if err != nil { + return nil, err + } + sets[i] = s + } + return &gsubAlternate1{cov: cov, sets: sets}, nil +} + +func (s *gsubAlternate1) sub(_ *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + idx, ok := s.cov[g[i]] + if !ok { + return g, i, false + } + alts := s.sets[idx] + if len(alts) == 0 { + return g, i, false + } + g[i] = alts[0] + return g, i + 1, true +} + +// --- Type 4: ligature substitution ------------------------------------------ + +// ligature is one ligature rule: the glyphs after the first component (which is +// keyed through coverage) and the ligature glyph they collapse into. +type ligature struct { + glyph GlyphIndex + rest []GlyphIndex +} + +// gsubLigature1 is a ligature-substitution format 1 subtable. sets[i] holds the +// ligatures whose first component is the glyph with coverage index i. +type gsubLigature1 struct { + cov map[GlyphIndex]int + sets [][]ligature +} + +// parseGSUBLigature decodes a ligature-substitution (format 1) subtable. +func parseGSUBLigature(b []byte) (gsubSubtable, error) { + r := reader{b: b} + r.skip(2) // substFormat (always 1) + covOff := int(r.u16()) + setCount := int(r.u16()) + setOffs := make([]int, setCount) + for i := 0; i < setCount; i++ { + setOffs[i] = int(r.u16()) + } + if r.err != nil { + return nil, fmt.Errorf("opentype: ligature subst: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets := make([][]ligature, setCount) + for i, so := range setOffs { + ls, err := parseLigatureSet(subslice(b, so)) + if err != nil { + return nil, err + } + sets[i] = ls + } + return &gsubLigature1{cov: cov, sets: sets}, nil +} + +// parseLigatureSet decodes a LigatureSet table. +func parseLigatureSet(b []byte) ([]ligature, 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: ligatureSet: %w", r.err) + } + ligs := make([]ligature, count) + for i, off := range offs { + lg, err := parseLigature(subslice(b, off)) + if err != nil { + return nil, err + } + ligs[i] = lg + } + return ligs, nil +} + +// parseLigature decodes a Ligature table. componentCount counts the first +// component too, so componentCount-1 trailing glyph ids follow. +func parseLigature(b []byte) (ligature, error) { + r := reader{b: b} + glyph := GlyphIndex(r.u16()) + compCount := int(r.u16()) + rest := make([]GlyphIndex, 0, compCount) + for i := 1; i < compCount; i++ { + rest = append(rest, GlyphIndex(r.u16())) + } + if r.err != nil { + return ligature{}, fmt.Errorf("opentype: ligature: %w", r.err) + } + return ligature{glyph: glyph, rest: rest}, nil +} + +func (s *gsubLigature1) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + ci, ok := s.cov[g[i]] + if !ok { + return g, i, false + } + for _, lig := range s.sets[ci] { + matched, _, ok := a.skip.matchForward(g, i+1, glyphPredsGI(lig.rest)) + if !ok { + continue + } + // Replace the first component with the ligature and delete the other + // matched components, keeping any skipped glyphs (marks) that fell + // between them in place. + rem := make(map[int]bool, len(matched)) + for _, p := range matched { + rem[p] = true + } + out := make([]GlyphIndex, 0, len(g)-len(matched)) + for k := 0; k < len(g); k++ { + switch { + case k == i: + out = append(out, lig.glyph) + case rem[k]: + // deleted component + default: + out = append(out, g[k]) + } + } + a.spliceLigClusters(i, matched) + return out, i + 1, true + } + return g, i, false +} + +// spliceClusters mirrors, on the tracked cluster slice, a substitution that +// replaced `consumed` input glyphs at position i with `produced` output glyphs. +// The produced glyphs all inherit the cluster of the first consumed glyph, so a +// decomposition's parts share their source position and a ligature's result +// keeps its first component's. It is a no-op when tracking is off (clusters +// nil), which is the case for plain Apply/ApplyMasked. +func (a *gsubApplier) spliceClusters(i, consumed, produced int) { + if a.clusters == nil { + return + } + c := a.clusters + src := c[i] + nc := make([]int, 0, len(c)-consumed+produced) + nc = append(nc, c[:i]...) + for k := 0; k < produced; k++ { + nc = append(nc, src) + } + nc = append(nc, c[i+consumed:]...) + a.clusters = nc +} + +// spliceLigClusters mirrors, on the tracked cluster slice, a ligature that +// replaced the first component at i and deleted the components at the `removed` +// positions (which may be non-adjacent when skipped marks fell between them). +// The ligature slot keeps the first component's cluster; the deleted components +// drop and every other glyph (including any interleaved marks) keeps its own. It +// is a no-op when tracking is off (clusters nil). +func (a *gsubApplier) spliceLigClusters(i int, removed []int) { + if a.clusters == nil { + return + } + rem := make(map[int]bool, len(removed)) + for _, p := range removed { + rem[p] = true + } + nc := make([]int, 0, len(a.clusters)-len(removed)) + for k, cl := range a.clusters { + if rem[k] { + continue + } + nc = append(nc, cl) + } + a.clusters = nc +} + +// --- Sequence-matching helpers (shared by types 5, 6 and 8) ----------------- + +// classOf returns the class of glyph g in a ClassDef map; glyphs absent from the +// map belong to class 0. +func classOf(m map[GlyphIndex]int, g GlyphIndex) int { return m[g] } + +// glyphPredsU builds glyph-id-equality predicates from a uint16 sequence (the +// on-disk form of GSUB input, backtrack and lookahead glyph sequences). +func glyphPredsU(seq []uint16) []glyphPred { + preds := make([]glyphPred, len(seq)) + for k := range seq { + v := seq[k] + preds[k] = func(g GlyphIndex) bool { return uint16(g) == v } + } + return preds +} + +// glyphPredsGI builds glyph-id-equality predicates from a GlyphIndex sequence +// (used for ligature component matching). +func glyphPredsGI(seq []GlyphIndex) []glyphPred { + preds := make([]glyphPred, len(seq)) + for k := range seq { + v := seq[k] + preds[k] = func(g GlyphIndex) bool { return g == v } + } + return preds +} + +// classPredsU builds class-equality predicates from a uint16 class sequence and +// a ClassDef map. +func classPredsU(seq []uint16, cd map[GlyphIndex]int) []glyphPred { + preds := make([]glyphPred, len(seq)) + for k := range seq { + v := int(seq[k]) + preds[k] = func(g GlyphIndex) bool { return classOf(cd, g) == v } + } + return preds +} + +// --- Nested lookup application ---------------------------------------------- + +// runRecords applies the SequenceLookupRecords of a matched context. positions +// holds the absolute run indices of the matched input glyphs (skipped glyphs +// excluded), so a record's seqIndex selects the position it acts on. A record +// whose seqIndex lies past the matched input, or that targets a run position no +// longer present after an earlier record rewrote the run, is a no-op. +func (a *gsubApplier) runRecords(recs []seqLookupRecord, g []GlyphIndex, positions []int) []GlyphIndex { + for _, rec := range recs { + si := int(rec.seqIndex) + if si >= len(positions) || positions[si] >= len(g) { + continue + } + g = a.applyLookupAt(int(rec.lookupIndex), g, positions[si]) + } + return g +} + +// applyLookupAt applies lookup li at a single position, taking the first +// matching subtable. Out-of-range indices and excessive recursion are no-ops. +// The nested lookup matches under its own lookupFlag, so the applier's skipper +// is swapped for the duration and restored afterwards. +func (a *gsubApplier) applyLookupAt(li int, g []GlyphIndex, pos int) []GlyphIndex { + if li < 0 || li >= len(a.lookups) || a.depth >= maxGSUBNest { + return g + } + a.depth++ + saved := a.skip + a.skip = a.skipperFor(a.lookups[li]) + for _, st := range a.lookups[li].subtables { + if out, _, ok := st.sub(a, g, pos); ok { + g = out + break + } + } + a.skip = saved + a.depth-- + return g +} + +// --- Type 5: contextual substitution ---------------------------------------- + +// seqRule is one (Class)SequenceRule: the input sequence (positions after the +// first, as glyph ids for format 1 or class values for format 2) and the nested +// lookup records to run on a match. +type seqRule struct { + input []uint16 + records []seqLookupRecord +} + +// parseSeqRule decodes a SequenceRule or ClassSequenceRule table. +func parseSeqRule(b []byte) (seqRule, error) { + r := reader{b: b} + glyphCount := int(r.u16()) + lookupCount := int(r.u16()) + input := readU16s(&r, glyphCount-1) + recs := parseSeqLookupRecords(&r, lookupCount) + if r.err != nil { + return seqRule{}, fmt.Errorf("opentype: seq rule: %w", r.err) + } + return seqRule{input: input, records: recs}, nil +} + +// parseSeqRuleSet decodes a SequenceRuleSet / ClassSequenceRuleSet table. +func parseSeqRuleSet(b []byte) ([]seqRule, error) { + r := reader{b: b} + n := int(r.u16()) + offs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: seq rule set: %w", r.err) + } + rules := make([]seqRule, n) + for i, off := range offs { + ru, err := parseSeqRule(subslice(b, int(off))) + if err != nil { + return nil, err + } + rules[i] = ru + } + return rules, nil +} + +// parseSeqRuleSets decodes a list of (Class)SequenceRuleSet offsets; a zero +// offset denotes an absent (empty) set. +func parseSeqRuleSets(b []byte, offs []uint16) ([][]seqRule, error) { + sets := make([][]seqRule, len(offs)) + for i, off := range offs { + if off == 0 { + continue + } + rs, err := parseSeqRuleSet(subslice(b, int(off))) + if err != nil { + return nil, err + } + sets[i] = rs + } + return sets, nil +} + +// gsubContext1 is contextual substitution format 1 (glyph-based). +type gsubContext1 struct { + cov map[GlyphIndex]int + sets [][]seqRule +} + +// gsubContext2 is contextual substitution format 2 (class-based). +type gsubContext2 struct { + cov map[GlyphIndex]int + classDef map[GlyphIndex]int + sets [][]seqRule +} + +// gsubContext3 is contextual substitution format 3 (coverage-based). +type gsubContext3 struct { + covs []map[GlyphIndex]int + records []seqLookupRecord +} + +// parseGSUBContext decodes a contextual-substitution subtable (format 1/2/3). +func parseGSUBContext(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + switch format { + case 1: + covOff := int(r.u16()) + n := int(r.u16()) + setOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: context 1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets, err := parseSeqRuleSets(b, setOffs) + if err != nil { + return nil, err + } + return &gsubContext1{cov: cov, sets: sets}, nil + case 2: + covOff := int(r.u16()) + classOff := int(r.u16()) + n := int(r.u16()) + setOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: context 2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + cd, err := parseClassDef(subslice(b, classOff)) + if err != nil { + return nil, err + } + sets, err := parseSeqRuleSets(b, setOffs) + if err != nil { + return nil, err + } + return &gsubContext2{cov: cov, classDef: cd, sets: sets}, nil + case 3: + glyphCount := int(r.u16()) + lookupCount := int(r.u16()) + covOffs := readU16s(&r, glyphCount) + recs := parseSeqLookupRecords(&r, lookupCount) + if r.err != nil { + return nil, fmt.Errorf("opentype: context 3: %w", r.err) + } + covs, err := parseCoverageList(b, covOffs) + if err != nil { + return nil, err + } + return &gsubContext3{covs: covs, records: recs}, nil + default: + return nil, fmt.Errorf("opentype: context format %d", format) + } +} + +func (s *gsubContext1) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + ci, ok := s.cov[g[i]] + if !ok || ci >= len(s.sets) { + return g, i, false + } + for _, rule := range s.sets[ci] { + matched, end, ok := a.skip.matchForward(g, i+1, glyphPredsU(rule.input)) + if !ok { + continue + } + out := a.runRecords(rule.records, g, append([]int{i}, matched...)) + return out, end, true + } + return g, i, false +} + +func (s *gsubContext2) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + if _, ok := s.cov[g[i]]; !ok { + return g, i, false + } + cls := classOf(s.classDef, g[i]) + if cls >= len(s.sets) { + return g, i, false + } + for _, rule := range s.sets[cls] { + matched, end, ok := a.skip.matchForward(g, i+1, classPredsU(rule.input, s.classDef)) + if !ok { + continue + } + out := a.runRecords(rule.records, g, append([]int{i}, matched...)) + return out, end, true + } + return g, i, false +} + +func (s *gsubContext3) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + matched, end, ok := a.skip.matchContext(g, i, s.covs) + if !ok { + return g, i, false + } + out := a.runRecords(s.records, g, matched) + return out, end, true +} + +// --- Type 6: chaining-contextual substitution ------------------------------- + +// chainRule is one Chain(Class)SequenceRule: backtrack, input (positions after +// the first) and lookahead sequences, plus the nested lookup records. +type chainRule struct { + backtrack []uint16 + input []uint16 + lookahead []uint16 + records []seqLookupRecord +} + +// parseChainRule decodes a ChainSequenceRule or ChainClassSequenceRule table. +func parseChainRule(b []byte) (chainRule, error) { + r := reader{b: b} + btCount := int(r.u16()) + backtrack := readU16s(&r, btCount) + inCount := int(r.u16()) + input := readU16s(&r, inCount-1) + laCount := int(r.u16()) + lookahead := readU16s(&r, laCount) + lookupCount := int(r.u16()) + recs := parseSeqLookupRecords(&r, lookupCount) + if r.err != nil { + return chainRule{}, fmt.Errorf("opentype: chain rule: %w", r.err) + } + return chainRule{backtrack: backtrack, input: input, lookahead: lookahead, records: recs}, nil +} + +// parseChainRuleSet decodes a ChainSequenceRuleSet table. +func parseChainRuleSet(b []byte) ([]chainRule, error) { + r := reader{b: b} + n := int(r.u16()) + offs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: chain rule set: %w", r.err) + } + rules := make([]chainRule, n) + for i, off := range offs { + ru, err := parseChainRule(subslice(b, int(off))) + if err != nil { + return nil, err + } + rules[i] = ru + } + return rules, nil +} + +// parseChainRuleSets decodes a list of ChainSequenceRuleSet offsets; a zero +// offset denotes an absent (empty) set. +func parseChainRuleSets(b []byte, offs []uint16) ([][]chainRule, error) { + sets := make([][]chainRule, len(offs)) + for i, off := range offs { + if off == 0 { + continue + } + rs, err := parseChainRuleSet(subslice(b, int(off))) + if err != nil { + return nil, err + } + sets[i] = rs + } + return sets, nil +} + +// gsubChain1 is chaining-contextual substitution format 1 (glyph-based). +type gsubChain1 struct { + cov map[GlyphIndex]int + sets [][]chainRule +} + +// gsubChain2 is chaining-contextual substitution format 2 (class-based). +type gsubChain2 struct { + cov map[GlyphIndex]int + backtrack map[GlyphIndex]int + input map[GlyphIndex]int + lookahead map[GlyphIndex]int + sets [][]chainRule +} + +// gsubChain3 is chaining-contextual substitution format 3 (coverage-based). +type gsubChain3 struct { + backtrack []map[GlyphIndex]int + input []map[GlyphIndex]int + lookahead []map[GlyphIndex]int + records []seqLookupRecord +} + +// parseGSUBChain decodes a chaining-contextual subtable (format 1/2/3). +func parseGSUBChain(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + switch format { + case 1: + covOff := int(r.u16()) + n := int(r.u16()) + setOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: chain 1: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + sets, err := parseChainRuleSets(b, setOffs) + if err != nil { + return nil, err + } + return &gsubChain1{cov: cov, sets: sets}, nil + case 2: + covOff := int(r.u16()) + btOff := int(r.u16()) + inOff := int(r.u16()) + laOff := int(r.u16()) + n := int(r.u16()) + setOffs := readU16s(&r, n) + if r.err != nil { + return nil, fmt.Errorf("opentype: chain 2: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + btCD, err := parseClassDef(subslice(b, btOff)) + if err != nil { + return nil, err + } + inCD, err := parseClassDef(subslice(b, inOff)) + if err != nil { + return nil, err + } + laCD, err := parseClassDef(subslice(b, laOff)) + if err != nil { + return nil, err + } + sets, err := parseChainRuleSets(b, setOffs) + if err != nil { + return nil, err + } + return &gsubChain2{cov: cov, backtrack: btCD, input: inCD, lookahead: laCD, sets: sets}, nil + case 3: + btCount := int(r.u16()) + btOffs := readU16s(&r, btCount) + inCount := int(r.u16()) + inOffs := readU16s(&r, inCount) + laCount := int(r.u16()) + laOffs := readU16s(&r, laCount) + lookupCount := int(r.u16()) + recs := parseSeqLookupRecords(&r, lookupCount) + if r.err != nil { + return nil, fmt.Errorf("opentype: chain 3: %w", r.err) + } + bt, err := parseCoverageList(b, btOffs) + if err != nil { + return nil, err + } + in, err := parseCoverageList(b, inOffs) + if err != nil { + return nil, err + } + la, err := parseCoverageList(b, laOffs) + if err != nil { + return nil, err + } + return &gsubChain3{backtrack: bt, input: in, lookahead: la, records: recs}, nil + default: + return nil, fmt.Errorf("opentype: chain format %d", format) + } +} + +func (s *gsubChain1) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + ci, ok := s.cov[g[i]] + if !ok || ci >= len(s.sets) { + return g, i, false + } + for _, rule := range s.sets[ci] { + matched, end, ok := a.skip.matchForward(g, i+1, glyphPredsU(rule.input)) + if !ok || + !a.skip.matchBackward(g, i-1, glyphPredsU(rule.backtrack)) { + continue + } + if _, _, ok := a.skip.matchForward(g, end, glyphPredsU(rule.lookahead)); !ok { + continue + } + out := a.runRecords(rule.records, g, append([]int{i}, matched...)) + return out, end, true + } + return g, i, false +} + +func (s *gsubChain2) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + if _, ok := s.cov[g[i]]; !ok { + return g, i, false + } + cls := classOf(s.input, g[i]) + if cls >= len(s.sets) { + return g, i, false + } + for _, rule := range s.sets[cls] { + matched, end, ok := a.skip.matchForward(g, i+1, classPredsU(rule.input, s.input)) + if !ok || + !a.skip.matchBackward(g, i-1, classPredsU(rule.backtrack, s.backtrack)) { + continue + } + if _, _, ok := a.skip.matchForward(g, end, classPredsU(rule.lookahead, s.lookahead)); !ok { + continue + } + out := a.runRecords(rule.records, g, append([]int{i}, matched...)) + return out, end, true + } + return g, i, false +} + +func (s *gsubChain3) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + matched, end, ok := a.skip.matchContext(g, i, s.input) + if !ok || + !a.skip.matchBackward(g, i-1, covPreds(s.backtrack)) { + return g, i, false + } + if _, _, ok := a.skip.matchForward(g, end, covPreds(s.lookahead)); !ok { + return g, i, false + } + out := a.runRecords(s.records, g, matched) + return out, end, true +} + +// --- Type 7: extension substitution ----------------------------------------- + +// parseGSUBExtension decodes an extension-substitution (format 1) subtable and +// resolves the indirection to the real subtable. +func parseGSUBExtension(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + if format != 1 { + return nil, fmt.Errorf("opentype: extension subst format %d", format) + } + extType := r.u16() + extOff := r.u32() + if r.err != nil { + return nil, fmt.Errorf("opentype: extension subst: %w", r.err) + } + if extType == 7 { + return nil, fmt.Errorf("opentype: extension subst nested (type 7)") + } + return parseGSUBSubtable(extType, subslice(b, int(extOff))) +} + +// --- Type 8: reverse chaining single substitution --------------------------- + +// gsubReverseChain1 is a reverse chaining single substitution: a covered glyph +// is replaced by substitute[coverageIndex] when its backtrack and lookahead +// contexts match. The enclosing lookup is applied to the run back-to-front. +type gsubReverseChain1 struct { + cov map[GlyphIndex]int + backtrack []map[GlyphIndex]int + lookahead []map[GlyphIndex]int + subst []GlyphIndex +} + +// parseGSUBReverse decodes a reverse-chaining single-substitution subtable. +func parseGSUBReverse(b []byte) (gsubSubtable, error) { + r := reader{b: b} + format := r.u16() + if format != 1 { + return nil, fmt.Errorf("opentype: reverse chain format %d", format) + } + covOff := int(r.u16()) + btCount := int(r.u16()) + btOffs := readU16s(&r, btCount) + laCount := int(r.u16()) + laOffs := readU16s(&r, laCount) + substCount := int(r.u16()) + substRaw := readU16s(&r, substCount) + if r.err != nil { + return nil, fmt.Errorf("opentype: reverse chain: %w", r.err) + } + cov, err := parseCoverage(subslice(b, covOff)) + if err != nil { + return nil, err + } + bt, err := parseCoverageList(b, btOffs) + if err != nil { + return nil, err + } + la, err := parseCoverageList(b, laOffs) + if err != nil { + return nil, err + } + subst := make([]GlyphIndex, len(substRaw)) + for i, v := range substRaw { + subst[i] = GlyphIndex(v) + } + return &gsubReverseChain1{cov: cov, backtrack: bt, lookahead: la, subst: subst}, nil +} + +func (s *gsubReverseChain1) sub(a *gsubApplier, g []GlyphIndex, i int) ([]GlyphIndex, int, bool) { + idx, ok := s.cov[g[i]] + if !ok || idx >= len(s.subst) { + return g, i, false + } + if !a.skip.matchBackward(g, i-1, covPreds(s.backtrack)) { + return g, i, false + } + if _, _, ok := a.skip.matchForward(g, i+1, covPreds(s.lookahead)); !ok { + return g, i, false + } + g[i] = s.subst[idx] + return g, i, true +} + +// --- Apply ------------------------------------------------------------------ + +// Apply runs the lookups activated by the given feature tags (for example +// "liga" and "calt") over the glyph run, returning the substituted run. The +// input slice is not modified. Feature tags with no matching lookups, and an +// empty run, leave the input unchanged. +func (g *gsub) Apply(glyphs []GlyphIndex, features ...string) []GlyphIndex { + want := make(map[string]bool, len(features)) + for _, f := range features { + want[f] = true + } + idxs := g.selectLookups("DFLT", "", want) + if len(idxs) == 0 { + return glyphs + } + out := append([]GlyphIndex(nil), glyphs...) + a := &gsubApplier{lookups: g.lookups, gdef: g.gdef} + for _, li := range idxs { + if li >= len(g.lookups) { + continue + } + out = a.applyLookup(g.lookups[li], out) + } + return out +} + +// applyLookup applies one lookup across the run. Reverse-chaining lookups are +// applied back-to-front; all others left-to-right, taking the first subtable +// that matches at each position. A glyph the lookup's flags ignore is not a +// starting position: it is skipped without attempting any subtable there. +func (a *gsubApplier) applyLookup(lk gsubLookup, glyphs []GlyphIndex) []GlyphIndex { + a.skip = a.skipperFor(lk) + if lk.reverse { + for i := len(glyphs) - 1; i >= 0; i-- { + if a.skip.skip(glyphs[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 a.skip.skip(glyphs[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 +} diff --git a/vendor/github.com/go-opentype/opentype/gvar.go b/vendor/github.com/go-opentype/opentype/gvar.go new file mode 100644 index 0000000..c7fa2c3 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/gvar.go @@ -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) +} diff --git a/vendor/github.com/go-opentype/opentype/hint.go b/vendor/github.com/go-opentype/opentype/hint.go new file mode 100644 index 0000000..a8a06c3 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/hint.go @@ -0,0 +1,1755 @@ +// 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" + "math" +) + +// This file is a self-contained TrueType instruction (hinting) bytecode +// interpreter: a stack machine over the TrueType virtual machine with graphics +// state, a storage area, the Control Value Table, and function / instruction +// definitions. It is standalone: it exposes newInterp and (*interp).run and is +// not yet wired into the glyph-rendering pipeline (that is a caller's job). +// +// # Coordinate model +// +// All interpreter coordinates are F26Dot6 (26.6 fixed point, 64 units per +// pixel) held in int32, matching the on-wire stack representation. run accepts +// and returns outlinePoint values whose x/y are fractional pixels; it converts +// to and from 26.6 at the boundary. The "original" (pre-hinting) coordinates +// used by relative-measurement opcodes are a snapshot of the scaled input, and +// IUP/IP/SHP treat the whole input point slice as a single closed contour. +// +// # Implemented opcodes +// +// The full standard TrueType instruction set (per the Apple/Microsoft +// specification, as FreeType implements) is supported: +// +// Push: NPUSHB NPUSHW PUSHB[0-7] PUSHW[0-7] +// Stack: DUP POP CLEAR SWAP DEPTH CINDEX MINDEX ROLL +// Arithmetic: ADD SUB DIV MUL ABS NEG FLOOR CEILING MAX MIN +// ROUND[abcd] NROUND[abcd] ODD EVEN +// Logic: AND OR NOT LT LTEQ GT GTEQ EQ NEQ +// Control: IF ELSE EIF JMPR JROT JROF DEBUG +// Storage: RS WS +// CVT: RCVT WCVTP WCVTF +// Vectors: SVTCA SPVTCA SFVTCA SPVTL SFVTL SDPVTL SPVFS SFVFS SFVTPV +// GPV GFV +// State: SRP0 SRP1 SRP2 SZP0 SZP1 SZP2 SZPS RTG RTHG RTDG RUTG RDTG +// ROFF SROUND S45ROUND SLOOP SMD SCVTCI SDB SDS SSW SSWCI +// FLIPON FLIPOFF SCANCTRL SCANTYPE INSTCTRL SANGW AA +// Functions: FDEF ENDF CALL LOOPCALL IDEF +// Points: GC SCFS MD MDAP MIAP MDRP MIRP MSIRP SHP SHC SHZ SHPIX IP +// ISECT ALIGNPTS ALIGNRP UTP IUP FLIPPT FLIPRGON FLIPRGOFF +// Delta: DELTAP1 DELTAP2 DELTAP3 DELTAC1 DELTAC2 DELTAC3 +// Info: MPPEM MPS GETINFO GETVARIATION GETDATA +// +// Instructions that have no meaningful effect for a supersampled, grayscale +// rasteriser (dropout control SCANCTRL/SCANTYPE, the grid-fit flags INSTCTRL, +// the obsolete SANGW/AA, single-width SSW/SSWCI) are accepted: they update the +// relevant graphics state and consume their stack arguments but perform no +// geometry change, matching FreeType's acceptance of them. GETVARIATION reports +// the default (unvaried) instance by pushing no axis coordinates; GETDATA +// returns the classic constant 17. +// +// # Reserved opcodes +// +// The opcodes the specification leaves undefined (0x28, 0x7B, 0x83, 0x84, 0x8F, +// 0x90 and 0x93-0xAF) have no handler. Any such opcode with no matching IDEF +// reaches a single covered fallback that reports "unimplemented opcode". +// Because every reserved opcode is a single byte (only the push family carries +// inline operands, and those are implemented), skipping such an opcode would be +// a simple one-byte advance; the fallback errors rather than silently skipping +// so that callers learn a program used an undefined instruction. + +// Interpreter guards. maxHintBudget bounds total instructions executed to stop +// runaway loops; maxCallDepth bounds CALL/LOOPCALL/IDEF recursion. +const ( + maxHintBudget = 1_000_000 + maxCallDepth = 64 +) + +var ( + errStackUnderflow = errors.New("opentype: hint: stack underflow") + errStackOverflow = errors.New("opentype: hint: stack overflow") + errHintBudget = errors.New("opentype: hint: instruction budget exhausted") + errCallDepth = errors.New("opentype: hint: call nesting too deep") + errBadProgram = errors.New("opentype: hint: truncated instruction stream") +) + +// gpoint is one interpreter point in F26Dot6. cur is the working position, +// orig the scaled pre-hinting position used for relative measurement; tx/ty +// record whether the point has been touched in x/y (for IUP). +type gpoint struct { + curX, curY int32 + origX, origY int32 + on bool + tx, ty bool +} + +// vec is a projection or freedom unit vector. +type vec struct{ x, y float64 } + +// round modes. +const ( + rndGrid = iota // period / phase / threshold rounding (RTG, RTHG, RTDG, SROUND…) + rndUp // round up to grid (RUTG) + rndDown // round down to grid (RDTG) + rndOff // no rounding (ROFF) +) + +// roundState captures the current rounding behaviour. For rndGrid the value is +// rounded with the given period/phase/threshold (all in F26Dot6 pixels). +type roundState struct { + mode int + period, phase, threshold float64 +} + +// gstate is the interpreter graphics state that run resets to the post-prep +// default before each glyph. +type gstate struct { + pv, fv, dv vec + rp0, rp1, rp2 int + zp0, zp1, zp2 int + loop int + round roundState + minDist int32 + cvtCutIn int32 + deltaBase int + deltaShift int + + // Accepted-but-inert state. These are updated and their arguments consumed + // so real font programs run to completion, but they have no effect on the + // supersampled grayscale geometry this interpreter produces. + autoFlip bool // FLIPON/FLIPOFF: auto-flip point on/off status + scanControl int32 // SCANCTRL dropout-control flags + scanType int32 // SCANTYPE dropout-control rule + instructControl int32 // INSTCTRL grid-fit control flags + singleWidth int32 // SSW single-width value (F26Dot6) + singleWidthCutIn int32 // SSWCI single-width cut-in (F26Dot6) +} + +// funcDef is a defined function's body (the bytes between FDEF and ENDF). +type funcDef struct{ body []byte } + +// interp is a TrueType bytecode interpreter bound to a font and pixel size. +type interp struct { + font *Font + ppem int + scale float64 // pixels per font unit + + stack []int32 + storage []int32 + cvt []int32 // F26Dot6, scaled to ppem + maxStack int + + funcs map[int32]funcDef + idefs map[uint8]funcDef + + gstate // live graphics state + dflt gstate // post-prep default, restored per glyph + + glyphZone []gpoint + twilight []gpoint + scratch gpoint // returned for out-of-range point access after setErr + + budget int + err error +} + +// setErr records the first error encountered. +func (it *interp) setErr(e error) { + if it.err == nil { + it.err = e + } +} + +// --- stack helpers ---------------------------------------------------------- + +func (it *interp) push(v int32) { + if len(it.stack) >= it.maxStack { + it.setErr(errStackOverflow) + return + } + it.stack = append(it.stack, v) +} + +func (it *interp) pop() int32 { + n := len(it.stack) + if n == 0 { + it.setErr(errStackUnderflow) + return 0 + } + v := it.stack[n-1] + it.stack = it.stack[:n-1] + return v +} + +// --- construction ----------------------------------------------------------- + +// newInterp builds an interpreter for font at ppem pixels per em and runs the +// font program (fpgm) then the control-value program (prep) once, capturing +// the resulting graphics state as the per-glyph default. ppem must be positive. +func newInterp(font *Font, ppem int) (*interp, error) { + if ppem <= 0 { + return nil, fmt.Errorf("opentype: hint: non-positive ppem %d", ppem) + } + it := &interp{ + font: font, + ppem: ppem, + scale: float64(ppem) / float64(font.unitsPerEm), + maxStack: font.maxStackElements, + storage: make([]int32, font.maxStorage), + funcs: make(map[int32]funcDef, font.maxFunctionDefs), + idefs: make(map[uint8]funcDef), + } + if it.maxStack < 32 { + it.maxStack = 32 // keep room for the interpreter's own working values + } + // Scale the CVT into F26Dot6 pixels. + it.cvt = make([]int32, len(font.cvt)) + for i, v := range font.cvt { + it.cvt[i] = f26(float64(v) * it.scale) + } + it.twilight = make([]gpoint, font.maxTwilightPts) + + it.resetState() + if err := it.exec(font.fpgm, 0); err != nil { + return nil, err + } + it.resetState() + if err := it.exec(font.prep, 0); err != nil { + return nil, err + } + it.dflt = it.gstate + return it, nil +} + +// resetState restores the TrueType default graphics state. +func (it *interp) resetState() { + it.gstate = gstate{ + pv: vec{1, 0}, + fv: vec{1, 0}, + dv: vec{1, 0}, + zp0: 1, // default reference zone is the glyph zone + zp1: 1, + zp2: 1, + loop: 1, + round: roundState{mode: rndGrid, period: 64, phase: 0, threshold: 32}, + minDist: 64, // 1 pixel + cvtCutIn: f26(17.0 / 16.0), + deltaBase: 9, + deltaShift: 3, + autoFlip: true, // TrueType default is auto-flip enabled + } +} + +// f26 converts fractional pixels to F26Dot6, rounding to nearest. +func f26(px float64) int32 { return int32(math.Round(px * 64)) } + +// --- run -------------------------------------------------------------------- + +// run hints one glyph. pts are the glyph's points in fractional pixels; it +// returns a new slice with the hinted positions. The graphics state is reset to +// the post-prep default first. The whole slice is treated as a single closed +// contour for IUP/IP/SHP. +func (it *interp) run(pts []outlinePoint, instructions []byte) ([]outlinePoint, error) { + it.gstate = it.dflt + it.stack = it.stack[:0] + it.err = nil + for i := range it.twilight { + it.twilight[i] = gpoint{} + } + it.glyphZone = make([]gpoint, len(pts)) + for i, p := range pts { + x, y := f26(p.x), f26(p.y) + it.glyphZone[i] = gpoint{curX: x, curY: y, origX: x, origY: y, on: p.on} + } + if err := it.exec(instructions, 0); err != nil { + return nil, err + } + out := make([]outlinePoint, len(pts)) + for i, g := range it.glyphZone { + out[i] = outlinePoint{x: float64(g.curX) / 64, y: float64(g.curY) / 64, on: g.on} + } + return out, nil +} + +// --- point / zone access ---------------------------------------------------- + +func (it *interp) zone(zp int) []gpoint { + if zp == 0 { + return it.twilight + } + return it.glyphZone +} + +// pt returns the point at idx in the zone selected by zp. On an out-of-range +// index it records an error and returns a throwaway scratch point so callers +// need not nil-check (the surrounding instruction is abandoned via it.err). +func (it *interp) pt(zp, idx int) *gpoint { + z := it.zone(zp) + if idx < 0 || idx >= len(z) { + it.setErr(fmt.Errorf("opentype: hint: point %d out of range in zone %d", idx, zp)) + it.scratch = gpoint{} + return &it.scratch + } + return &z[idx] +} + +// --- vectors / projection --------------------------------------------------- + +func (it *interp) project(x, y int32) float64 { + return float64(x)*it.pv.x + float64(y)*it.pv.y +} + +func (it *interp) dualProject(x, y int32) float64 { + return float64(x)*it.dv.x + float64(y)*it.dv.y +} + +// moveBy shifts a point along the freedom vector so its projection changes by +// dp (F26Dot6). It also marks the point touched on the axes the freedom vector +// spans. If the freedom and projection vectors are perpendicular the point +// cannot move and the call is a no-op. +func (it *interp) moveBy(p *gpoint, dp float64) { + f := it.fv.x*it.pv.x + it.fv.y*it.pv.y + if f == 0 { + return + } + p.curX += int32(math.Round(dp * it.fv.x / f)) + p.curY += int32(math.Round(dp * it.fv.y / f)) + if it.fv.x != 0 { + p.tx = true + } + if it.fv.y != 0 { + p.ty = true + } +} + +// --- rounding --------------------------------------------------------------- + +func (rs roundState) apply(v float64) float64 { + if rs.mode == rndOff { + return v + } + sign := 1.0 + if v < 0 { + sign, v = -1, -v + } + switch rs.mode { + case rndUp: + v = math.Ceil(v/64) * 64 + case rndDown: + v = math.Floor(v/64) * 64 + default: // rndGrid + v = math.Floor((v-rs.phase+rs.threshold)/rs.period)*rs.period + rs.phase + if v < 0 { + v = 0 + } + } + return sign * v +} + +// --- instruction length ----------------------------------------------------- + +// opLen returns the total byte length of the instruction at prog[pc], including +// any inline push operands, or -1 if it runs past the end of prog. +func opLen(prog []byte, pc int) int { + op := prog[pc] + switch { + case op == 0x40: // NPUSHB + if pc+1 >= len(prog) { + return -1 + } + return 2 + int(prog[pc+1]) + case op == 0x41: // NPUSHW + if pc+1 >= len(prog) { + return -1 + } + return 2 + 2*int(prog[pc+1]) + case op >= 0xB0 && op <= 0xB7: // PUSHB[n] + return 1 + int(op-0xB0) + 1 + case op >= 0xB8 && op <= 0xBF: // PUSHW[n] + return 1 + 2*(int(op-0xB8)+1) + default: + return 1 + } +} + +// skipToElseEif advances from the instruction after an IF whose condition was +// false to the matching ELSE (returning the index after it) or EIF (index after +// it), honouring nested IF/EIF. onlyEif skips a taken ELSE straight to EIF. +func skipToElseEif(prog []byte, pc int, onlyEif bool) (int, error) { + depth := 0 + for pc < len(prog) { + op := prog[pc] + l := opLen(prog, pc) + if l < 0 { + return 0, errBadProgram + } + switch { + case op == 0x58: // IF + depth++ + case op == 0x1B && depth == 0 && !onlyEif: // ELSE at our level + return pc + 1, nil + case op == 0x59: // EIF + if depth == 0 { + return pc + 1, nil + } + depth-- + } + pc += l + } + return 0, errBadProgram +} + +// scanFDEF returns the body between the instruction after FDEF at fpc and its +// matching ENDF, plus the index just past ENDF. +func scanFDEF(prog []byte, fpc int) ([]byte, int, error) { + start := fpc + pc := fpc + for pc < len(prog) { + op := prog[pc] + l := opLen(prog, pc) + if l < 0 { + return nil, 0, errBadProgram + } + if op == 0x2D { // ENDF + return prog[start:pc], pc + 1, nil + } + pc += l + } + return nil, 0, errBadProgram +} + +// --- execution -------------------------------------------------------------- + +// exec runs a program to completion (or the first error), managing the program +// counter, jumps, conditionals and function definitions. depth guards call +// recursion. +func (it *interp) exec(prog []byte, depth int) error { + if depth > maxCallDepth { + return errCallDepth + } + if depth == 0 { + it.budget = maxHintBudget + } + pc := 0 + for pc < len(prog) { + it.budget-- + if it.budget < 0 { + return errHintBudget + } + op := prog[pc] + next := pc + 1 + jumped, err := it.step(prog, &pc, &next, op, depth) + if err != nil { + return err + } + if jumped { + continue + } + pc = next + } + return nil +} + +// step executes one opcode. It returns jumped=true when it has already set *pc +// (a jump / conditional / push adjusted the counter and the caller must not use +// next). For ordinary opcodes it leaves *next at pc+1 (adjusted for inline +// operands) and returns jumped=false. +func (it *interp) step(prog []byte, pc, next *int, op uint8, depth int) (bool, error) { + switch { + case op == 0x40: // NPUSHB + return true, it.pushBytes(prog, pc, false, -1) + case op == 0x41: // NPUSHW + return true, it.pushWords(prog, pc, false, -1) + case op >= 0xB0 && op <= 0xB7: // PUSHB[n] + return true, it.pushBytes(prog, pc, true, int(op-0xB0)+1) + case op >= 0xB8 && op <= 0xBF: // PUSHW[n] + return true, it.pushWords(prog, pc, true, int(op-0xB8)+1) + case op == 0x58: // IF + return true, it.doIF(prog, pc) + case op == 0x1B: // ELSE (only reached when executing the true branch) + np, err := skipToElseEif(prog, *pc+1, true) + *pc = np + return true, err + case op == 0x59: // EIF + return false, nil // no-op marker + case op == 0x1C: // JMPR + return true, it.jump(prog, pc, jmpAlways) + case op == 0x78: // JROT + return true, it.jump(prog, pc, jmpTrue) + case op == 0x79: // JROF + return true, it.jump(prog, pc, jmpFalse) + case op == 0x2C: // FDEF + return true, it.doFDEF(prog, pc) + case op == 0x89: // IDEF + return true, it.doIDEF(prog, pc) + case op == 0x2D: // ENDF + *pc = len(prog) // terminate this program + return true, nil + case op == 0x2B: // CALL + return false, it.doCALL(depth) + case op == 0x2A: // LOOPCALL + return false, it.doLOOPCALL(depth) + default: + return false, it.dataOp(op, depth) + } +} + +// pushBytes / pushWords implement the push family. When fixed is true count is +// the operand count; otherwise the count byte follows the opcode. +func (it *interp) pushBytes(prog []byte, pc *int, fixed bool, count int) error { + p := *pc + 1 + if !fixed { + if p >= len(prog) { + return errBadProgram + } + count = int(prog[p]) + p++ + } + if p+count > len(prog) { + return errBadProgram + } + for i := 0; i < count; i++ { + it.push(int32(prog[p+i])) + } + *pc = p + count + return it.err +} + +func (it *interp) pushWords(prog []byte, pc *int, fixed bool, count int) error { + p := *pc + 1 + if !fixed { + if p >= len(prog) { + return errBadProgram + } + count = int(prog[p]) + p++ + } + if p+2*count > len(prog) { + return errBadProgram + } + for i := 0; i < count; i++ { + it.push(int32(int16(uint16(prog[p+2*i])<<8 | uint16(prog[p+2*i+1])))) + } + *pc = p + 2*count + return it.err +} + +func (it *interp) doIF(prog []byte, pc *int) error { + cond := it.pop() + if it.err != nil { + return it.err + } + if cond != 0 { + *pc = *pc + 1 // fall into the true branch + return nil + } + np, err := skipToElseEif(prog, *pc+1, false) + *pc = np + return err +} + +type jmpKind int + +const ( + jmpAlways jmpKind = iota + jmpTrue + jmpFalse +) + +func (it *interp) jump(prog []byte, pc *int, kind jmpKind) error { + take := true + if kind != jmpAlways { + e := it.pop() + take = (kind == jmpTrue) == (e != 0) + } + offset := it.pop() + if it.err != nil { + return it.err + } + if !take { + *pc = *pc + 1 + return nil + } + target := *pc + int(offset) + if target < 0 || target > len(prog) { + return errBadProgram + } + *pc = target + return nil +} + +func (it *interp) doFDEF(prog []byte, pc *int) error { + id := it.pop() + if it.err != nil { + return it.err + } + body, after, err := scanFDEF(prog, *pc+1) + if err != nil { + return err + } + it.funcs[id] = funcDef{body: body} + *pc = after + return nil +} + +func (it *interp) doIDEF(prog []byte, pc *int) error { + op := it.pop() + if it.err != nil { + return it.err + } + body, after, err := scanFDEF(prog, *pc+1) + if err != nil { + return err + } + it.idefs[uint8(op)] = funcDef{body: body} + *pc = after + return nil +} + +func (it *interp) doCALL(depth int) error { + id := it.pop() + if it.err != nil { + return it.err + } + fd, ok := it.funcs[id] + if !ok { + return fmt.Errorf("opentype: hint: call to undefined function %d", id) + } + return it.exec(fd.body, depth+1) +} + +func (it *interp) doLOOPCALL(depth int) error { + id := it.pop() + n := it.pop() + if it.err != nil { + return it.err + } + fd, ok := it.funcs[id] + if !ok { + return fmt.Errorf("opentype: hint: loopcall to undefined function %d", id) + } + for i := int32(0); i < n; i++ { + if err := it.exec(fd.body, depth+1); err != nil { + return err + } + } + return nil +} + +// dataOp handles every opcode that neither branches nor defines a function: +// the stack, arithmetic, state, CVT, storage, point and delta instructions. +func (it *interp) dataOp(op uint8, depth int) error { + switch op { + // stack manipulation + case 0x20: // DUP + v := it.pop() + it.push(v) + it.push(v) + case 0x21: // POP + it.pop() + case 0x22: // CLEAR + it.stack = it.stack[:0] + case 0x23: // SWAP + a := it.pop() + b := it.pop() + it.push(a) + it.push(b) + case 0x24: // DEPTH + it.push(int32(len(it.stack))) + case 0x25: // CINDEX + it.copyIndex(false) + case 0x26: // MINDEX + it.copyIndex(true) + case 0x8A: // ROLL + a := it.pop() + b := it.pop() + c := it.pop() + it.push(b) + it.push(a) + it.push(c) + + // arithmetic and logic + case 0x60: // ADD + b := it.pop() + it.push(it.pop() + b) + case 0x61: // SUB + b := it.pop() + it.push(it.pop() - b) + case 0x63: // MUL (F26Dot6) + b := it.pop() + a := it.pop() + it.push(int32((int64(a) * int64(b)) / 64)) + case 0x62: // DIV (F26Dot6) + b := it.pop() + a := it.pop() + if b == 0 { + return errors.New("opentype: hint: division by zero") + } + it.push(int32((int64(a) * 64) / int64(b))) + case 0x64: // ABS + v := it.pop() + if v < 0 { + v = -v + } + it.push(v) + case 0x65: // NEG + it.push(-it.pop()) + case 0x66: // FLOOR + it.push(it.pop() &^ 63) + case 0x67: // CEILING + it.push((it.pop() + 63) &^ 63) + case 0x8B: // MAX + b := it.pop() + a := it.pop() + it.push(maxI32(a, b)) + case 0x8C: // MIN + b := it.pop() + a := it.pop() + it.push(minI32(a, b)) + case 0x5A: // AND + b := it.pop() + it.push(boolI(it.pop() != 0 && b != 0)) + case 0x5B: // OR + b := it.pop() + it.push(boolI(it.pop() != 0 || b != 0)) + case 0x5C: // NOT + it.push(boolI(it.pop() == 0)) + case 0x50: // LT + b := it.pop() + it.push(boolI(it.pop() < b)) + case 0x51: // LTEQ + b := it.pop() + it.push(boolI(it.pop() <= b)) + case 0x52: // GT + b := it.pop() + it.push(boolI(it.pop() > b)) + case 0x53: // GTEQ + b := it.pop() + it.push(boolI(it.pop() >= b)) + case 0x54: // EQ + b := it.pop() + it.push(boolI(it.pop() == b)) + case 0x55: // NEQ + b := it.pop() + it.push(boolI(it.pop() != b)) + + // storage + case 0x43: // RS + i := it.pop() + if it.err == nil { + if i < 0 || int(i) >= len(it.storage) { + return errors.New("opentype: hint: storage read out of range") + } + it.push(it.storage[i]) + } + case 0x42: // WS + v := it.pop() + i := it.pop() + if it.err == nil { + if i < 0 || int(i) >= len(it.storage) { + return errors.New("opentype: hint: storage write out of range") + } + it.storage[i] = v + } + + // control value table + case 0x45: // RCVT + it.push(it.readCVT(it.pop())) + case 0x44: // WCVTP (value already in pixels/F26Dot6) + v := it.pop() + it.writeCVT(it.pop(), v) + case 0x70: // WCVTF (value in font units, scale to pixels) + v := it.pop() + it.writeCVT(it.pop(), f26(float64(v)*it.scale)) + + // graphics-state setters + case 0x00, 0x01: // SVTCA + it.setAxis(op&1 != 0, true, true) + case 0x02, 0x03: // SPVTCA + it.setAxis(op&1 != 0, true, false) + case 0x04, 0x05: // SFVTCA + it.setAxis(op&1 != 0, false, true) + case 0x06, 0x07: // SPVTL: projection (and dual) vector from a line + it.doSxVTL(op&1 != 0, false) + case 0x08, 0x09: // SFVTL: freedom vector from a line + it.doSxVTL(op&1 != 0, true) + case 0x86, 0x87: // SDPVTL: dual projection vector from a line + it.doSDPVTL(op&1 != 0) + case 0x0A: // SPVFS: projection (and dual) vector from the stack + it.doSVFS(true) + case 0x0B: // SFVFS: freedom vector from the stack + it.doSVFS(false) + case 0x0C: // GPV: push the projection vector as two F2Dot14 components + it.push(f2dot14(it.pv.x)) + it.push(f2dot14(it.pv.y)) + case 0x0D: // GFV: push the freedom vector as two F2Dot14 components + it.push(f2dot14(it.fv.x)) + it.push(f2dot14(it.fv.y)) + case 0x0E: // SFVTPV: set the freedom vector to the projection vector + it.fv = it.pv + case 0x10: // SRP0 + it.rp0 = int(it.pop()) + case 0x11: // SRP1 + it.rp1 = int(it.pop()) + case 0x12: // SRP2 + it.rp2 = int(it.pop()) + case 0x13: // SZP0 + return it.setZone(0) + case 0x14: // SZP1 + return it.setZone(1) + case 0x15: // SZP2 + return it.setZone(2) + case 0x16: // SZPS + return it.setZone(3) + case 0x17: // SLOOP + it.loop = int(it.pop()) + case 0x18: // RTG + it.round = roundState{mode: rndGrid, period: 64, phase: 0, threshold: 32} + case 0x19: // RTHG + it.round = roundState{mode: rndGrid, period: 64, phase: 32, threshold: 32} + case 0x3D: // RTDG + it.round = roundState{mode: rndGrid, period: 32, phase: 0, threshold: 16} + case 0x7C: // RUTG + it.round = roundState{mode: rndUp} + case 0x7D: // RDTG + it.round = roundState{mode: rndDown} + case 0x7A: // ROFF + it.round = roundState{mode: rndOff} + case 0x76: // SROUND + it.setSuperRound(64) + case 0x77: // S45ROUND + it.setSuperRound(64 * math.Sqrt2 / 2) + case 0x1A: // SMD + it.minDist = it.pop() + case 0x1D: // SCVTCI + it.cvtCutIn = it.pop() + case 0x5E: // SDB + it.deltaBase = int(it.pop()) + case 0x5F: // SDS + it.deltaShift = int(it.pop()) + case 0x1F: // SSW: single-width value, given in font units + it.singleWidth = f26(float64(it.pop()) * it.scale) + case 0x1E: // SSWCI: single-width cut-in, already in F26Dot6 pixels + it.singleWidthCutIn = it.pop() + case 0x4D: // FLIPON: enable auto point on/off flipping + it.autoFlip = true + case 0x4E: // FLIPOFF: disable auto point on/off flipping + it.autoFlip = false + case 0x85: // SCANCTRL: dropout-control flags (inert here) + it.scanControl = it.pop() + case 0x8D: // SCANTYPE: dropout-control rule (inert here) + it.scanType = it.pop() + case 0x8E: // INSTCTRL: grid-fit control flags (inert here) + it.doINSTCTRL() + case 0x7E: // SANGW: set angle weight (obsolete: consume arg, no effect) + it.pop() + case 0x7F: // AA: adjust angle (obsolete: consume arg, no effect) + it.pop() + case 0x4F: // DEBUG: consume arg, no effect + it.pop() + + // rounding and measurement of stack values + case 0x68, 0x69, 0x6A, 0x6B: // ROUND[ab]: round per current state + it.push(int32(it.round.apply(float64(it.pop())))) + case 0x6C, 0x6D, 0x6E, 0x6F: // NROUND[ab]: no engine compensation -> identity + it.push(it.pop()) + case 0x56: // ODD: is the rounded value an odd number of pixels? + it.push(boolI(int32(it.round.apply(float64(it.pop())))&127 == 64)) + case 0x57: // EVEN: is the rounded value an even number of pixels? + it.push(boolI(int32(it.round.apply(float64(it.pop())))&127 == 0)) + + // measurement and movement + case 0x46, 0x47: // GC + it.doGC(op&1 != 0) + case 0x48: // SCFS + it.doSCFS() + case 0x2E, 0x2F: // MDAP + it.doMDAP(op&1 != 0) + case 0x3E, 0x3F: // MIAP + it.doMIAP(op&1 != 0) + case 0x38: // SHPIX + it.doSHPIX() + case 0x32, 0x33: // SHP + it.doSHP(op&1 != 0) + case 0x39: // IP + it.doIP() + case 0x3C: // ALIGNRP + it.doALIGNRP() + case 0x30, 0x31: // IUP + it.doIUP(op&1 != 0) + case 0x49, 0x4A: // MD: measure distance between two points + it.doMD(op&1 != 0) + case 0x3A, 0x3B: // MSIRP: move stack-indirect relative point + it.doMSIRP(op&1 != 0) + case 0x0F: // ISECT: move a point to the intersection of two lines + it.doISECT() + case 0x27: // ALIGNPTS: align two points to their midpoint + it.doALIGNPTS() + case 0x29: // UTP: untouch point + it.doUTP() + case 0x34, 0x35: // SHC: shift contour by a reference point's movement + it.doSHC(op&1 != 0) + case 0x36, 0x37: // SHZ: shift a whole zone by a reference point's movement + it.doSHZ(op&1 != 0) + case 0x80: // FLIPPT: flip on/off status of looped points + it.doFLIPPT() + case 0x81: // FLIPRGON: set a point range on-curve + it.doFlipRange(true) + case 0x82: // FLIPRGOFF: set a point range off-curve + it.doFlipRange(false) + + // delta exceptions + case 0x5D: // DELTAP1 + it.doDeltaP(0) + case 0x71: // DELTAP2 + it.doDeltaP(16) + case 0x72: // DELTAP3 + it.doDeltaP(32) + case 0x73: // DELTAC1 + it.doDeltaC(0) + case 0x74: // DELTAC2 + it.doDeltaC(16) + case 0x75: // DELTAC3 + it.doDeltaC(32) + + // information + case 0x4B: // MPPEM + it.push(int32(it.ppem)) + case 0x4C: // MPS (point size; we report ppem, assuming 72 dpi) + it.push(int32(it.ppem)) + case 0x88: // GETINFO + it.doGetInfo() + case 0x91: // GETVARIATION: default (unvaried) instance -> push no axis coords + case 0x92: // GETDATA: FreeType returns the classic constant 17 + it.push(17) + + default: + // MDRP 0xC0-0xDF, MIRP 0xE0-0xFF are handled by range; anything else is + // an IDEF-defined instruction or genuinely unimplemented. + switch { + case op >= 0xC0 && op <= 0xDF: + it.doMDRP(op) + case op >= 0xE0 && op <= 0xFF: + it.doMIRP(op) + default: + if fd, ok := it.idefs[op]; ok { + return it.exec(fd.body, depth+1) + } + return fmt.Errorf("opentype: hint: unimplemented opcode 0x%02X", op) + } + } + return it.err +} + +// copyIndex implements CINDEX (move==false) and MINDEX (move==true). +func (it *interp) copyIndex(move bool) { + k := int(it.pop()) + if it.err != nil { + return + } + n := len(it.stack) + if k <= 0 || k > n { + it.setErr(errStackUnderflow) + return + } + v := it.stack[n-k] + if move { + it.stack = append(it.stack[:n-k], it.stack[n-k+1:]...) + } + it.push(v) +} + +func (it *interp) readCVT(i int32) int32 { + if i < 0 || int(i) >= len(it.cvt) { + it.setErr(errors.New("opentype: hint: CVT read out of range")) + return 0 + } + return it.cvt[i] +} + +func (it *interp) writeCVT(i, v int32) { + if it.err != nil { + return + } + if i < 0 || int(i) >= len(it.cvt) { + it.setErr(errors.New("opentype: hint: CVT write out of range")) + return + } + it.cvt[i] = v +} + +// setAxis points the projection and/or freedom vectors at the x (xAxis true) or +// y axis. +func (it *interp) setAxis(xAxis, setProj, setFree bool) { + v := vec{0, 1} + if xAxis { + v = vec{1, 0} + } + if setProj { + it.pv = v + it.dv = v + } + if setFree { + it.fv = v + } +} + +// setZone implements SZP0/1/2 (which=0/1/2) and SZPS (which=3, all three). +func (it *interp) setZone(which int) error { + z := int(it.pop()) + if it.err != nil { + return it.err + } + if z != 0 && z != 1 { + return fmt.Errorf("opentype: hint: bad zone pointer %d", z) + } + switch which { + case 0: + it.zp0 = z + case 1: + it.zp1 = z + case 2: + it.zp2 = z + default: + it.zp0, it.zp1, it.zp2 = z, z, z + } + return nil +} + +// setSuperRound decodes an SROUND/S45ROUND selector byte into period/phase/ +// threshold. base is the grid period (64 for SROUND, 64·√2/2 for S45ROUND). +func (it *interp) setSuperRound(base float64) { + n := it.pop() + if it.err != nil { + return + } + rs := roundState{mode: rndGrid} + switch (n >> 6) & 3 { + case 0: + rs.period = base / 2 + case 2: + rs.period = base * 2 + default: // 1 and the reserved 3 + rs.period = base + } + switch (n >> 4) & 3 { + case 1: + rs.phase = rs.period / 4 + case 2: + rs.phase = rs.period / 2 + case 3: + rs.phase = rs.period * 3 / 4 + default: + rs.phase = 0 + } + if thr := n & 0x0F; thr == 0 { + rs.threshold = rs.period - 1 + } else { + rs.threshold = (float64(thr) - 4) / 8 * rs.period + } + it.round = rs +} + +func (it *interp) doGC(useOrig bool) { + idx := int(it.pop()) + p := it.pt(it.zp2, idx) + if it.err != nil { + return + } + var v float64 + if useOrig { + v = it.dualProject(p.origX, p.origY) + } else { + v = it.project(p.curX, p.curY) + } + it.push(int32(math.Round(v))) +} + +func (it *interp) doSCFS() { + val := it.pop() + idx := int(it.pop()) + p := it.pt(it.zp2, idx) + if it.err != nil { + return + } + it.moveBy(p, float64(val)-it.project(p.curX, p.curY)) +} + +func (it *interp) doMDAP(round bool) { + idx := int(it.pop()) + p := it.pt(it.zp0, idx) + if it.err != nil { + return + } + if round { + cur := it.project(p.curX, p.curY) + it.moveBy(p, it.round.apply(cur)-cur) + } else { + // touch without moving + if it.fv.x != 0 { + p.tx = true + } + if it.fv.y != 0 { + p.ty = true + } + } + it.rp0, it.rp1 = idx, idx +} + +func (it *interp) doMIAP(round bool) { + cvtIdx := it.pop() + idx := int(it.pop()) + d := it.readCVT(cvtIdx) + p := it.pt(it.zp0, idx) + if it.err != nil { + return + } + dist := float64(d) + // On the twilight zone the point starts at the CVT value. + if it.zp0 == 0 { + p.origX = int32(math.Round(dist * it.pv.x)) + p.origY = int32(math.Round(dist * it.pv.y)) + p.curX, p.curY = p.origX, p.origY + } + cur := it.project(p.curX, p.curY) + if round { + if math.Abs(dist-cur) > float64(it.cvtCutIn) { + dist = cur + } + dist = it.round.apply(dist) + } + it.moveBy(p, dist-cur) + it.rp0, it.rp1 = idx, idx +} + +// mdrpFlags decodes the shared MDRP/MIRP flag bits. +type mdrpFlags struct { + setRP0 bool + minDist bool + round bool +} + +func decodeMDRP(op uint8) mdrpFlags { + return mdrpFlags{ + setRP0: op&0x10 != 0, + minDist: op&0x08 != 0, + round: op&0x04 != 0, + } +} + +func (it *interp) applyDist(d float64, f mdrpFlags) float64 { + if f.round { + d = it.round.apply(d) + } + if f.minDist { + md := float64(it.minDist) + if d >= 0 && d < md { + d = md + } else if d < 0 && d > -md { + d = -md + } + } + return d +} + +func (it *interp) doMDRP(op uint8) { + f := decodeMDRP(op) + idx := int(it.pop()) + ref := it.pt(it.zp0, it.rp0) + p := it.pt(it.zp1, idx) + if it.err != nil { + return + } + orig := it.dualProject(p.origX-ref.origX, p.origY-ref.origY) + d := it.applyDist(orig, f) + cur := it.project(p.curX-ref.curX, p.curY-ref.curY) + it.moveBy(p, d-cur) + it.rp1, it.rp2 = it.rp0, idx + if f.setRP0 { + it.rp0 = idx + } +} + +func (it *interp) doMIRP(op uint8) { + f := decodeMDRP(op) + cvtIdx := it.pop() + idx := int(it.pop()) + cv := it.readCVT(cvtIdx) + ref := it.pt(it.zp0, it.rp0) + p := it.pt(it.zp1, idx) + if it.err != nil { + return + } + orig := it.dualProject(p.origX-ref.origX, p.origY-ref.origY) + dist := float64(cv) + // CVT cut-in: fall back to the original distance when the CVT disagrees. + if math.Abs(dist-orig) > float64(it.cvtCutIn) { + dist = orig + } + d := it.applyDist(dist, f) + cur := it.project(p.curX-ref.curX, p.curY-ref.curY) + it.moveBy(p, d-cur) + it.rp1, it.rp2 = it.rp0, idx + if f.setRP0 { + it.rp0 = idx + } +} + +func (it *interp) doSHPIX() { + amount := float64(it.pop()) + n := it.loop + it.loop = 1 + for i := 0; i < n; i++ { + idx := int(it.pop()) + p := it.pt(it.zp2, idx) + if it.err != nil { + return + } + p.curX += int32(math.Round(amount * it.fv.x)) + p.curY += int32(math.Round(amount * it.fv.y)) + if it.fv.x != 0 { + p.tx = true + } + if it.fv.y != 0 { + p.ty = true + } + } +} + +func (it *interp) doSHP(useRP1 bool) { + zp, rp := it.zp1, it.rp2 + if useRP1 { + zp, rp = it.zp0, it.rp1 + } + ref := it.pt(zp, rp) + if it.err != nil { + return + } + dp := it.project(ref.curX, ref.curY) - it.dualProject(ref.origX, ref.origY) + n := it.loop + it.loop = 1 + for i := 0; i < n; i++ { + idx := int(it.pop()) + p := it.pt(it.zp2, idx) + if it.err != nil { + return + } + it.moveBy(p, dp) + } +} + +func (it *interp) doALIGNRP() { + ref := it.pt(it.zp0, it.rp0) + if it.err != nil { + return + } + rpProj := it.project(ref.curX, ref.curY) + n := it.loop + it.loop = 1 + for i := 0; i < n; i++ { + idx := int(it.pop()) + p := it.pt(it.zp1, idx) + if it.err != nil { + return + } + it.moveBy(p, rpProj-it.project(p.curX, p.curY)) + } +} + +func (it *interp) doIP() { + rp1 := it.pt(it.zp0, it.rp1) + rp2 := it.pt(it.zp1, it.rp2) + if it.err != nil { + return + } + o1 := it.dualProject(rp1.origX, rp1.origY) + o2 := it.dualProject(rp2.origX, rp2.origY) + c1 := it.project(rp1.curX, rp1.curY) + c2 := it.project(rp2.curX, rp2.curY) + n := it.loop + it.loop = 1 + for i := 0; i < n; i++ { + idx := int(it.pop()) + p := it.pt(it.zp2, idx) + if it.err != nil { + return + } + op := it.dualProject(p.origX, p.origY) + var newProj float64 + if o2 == o1 { + newProj = c1 + (op - o1) + } else { + newProj = c1 + (op-o1)/(o2-o1)*(c2-c1) + } + it.moveBy(p, newProj-it.project(p.curX, p.curY)) + } +} + +// doIUP interpolates untouched points of the (single) contour along the x +// (xAxis true) or y axis, following the standard TrueType two-anchor scheme. +func (it *interp) doIUP(xAxis bool) { + z := it.glyphZone + n := len(z) + if n == 0 { + return + } + touched := func(i int) bool { + if xAxis { + return z[i].tx + } + return z[i].ty + } + cur := func(i int) int32 { + if xAxis { + return z[i].curX + } + return z[i].curY + } + orig := func(i int) int32 { + if xAxis { + return z[i].origX + } + return z[i].origY + } + setCur := func(i int, v int32) { + if xAxis { + z[i].curX = v + } else { + z[i].curY = v + } + } + + first := -1 + for i := 0; i < n; i++ { + if touched(i) { + first = i + break + } + } + if first < 0 { + return // nothing touched: leave the contour untouched + } + prev := first + for k := 1; k <= n; k++ { + i := (first + k) % n + if !touched(i) { + continue + } + it.iupSpan(prev, i, n, cur, orig, setCur) + prev = i + } +} + +// iupSpan interpolates the untouched points strictly between anchors a and b +// (indices in a ring of n points), shifting or interpolating each per its +// original coordinate relative to the two anchors. +func (it *interp) iupSpan(a, b, n int, cur, orig func(int) int32, setCur func(int, int32)) { + oa, ob := orig(a), orig(b) + ca, cb := cur(a), cur(b) + lo, hi := oa, ob + clo, chi := ca, cb + if lo > hi { + lo, hi, clo, chi = hi, lo, chi, clo + } + for k := 1; ; k++ { + i := (a + k) % n + if i == b { + break + } + o := orig(i) + switch { + case o <= lo: + setCur(i, o+(clo-lo)) + case o >= hi: + setCur(i, o+(chi-hi)) + default: + // lo < o < hi here, so hi-lo is strictly positive. + setCur(i, clo+int32(math.Round(float64(o-lo)/float64(hi-lo)*float64(chi-clo)))) + } + } +} + +// deltaStep converts a delta magnitude nibble into an F26Dot6 movement, using +// the current deltaShift (finer shifts give smaller steps). +func (it *interp) deltaStep(mag int32) float64 { + step := mag - 8 + if step >= 0 { + step++ // magnitudes skip zero: -8..-1, +1..+8 + } + return float64(step) * 64 / float64(int32(1)<<uint(it.deltaShift)) +} + +func (it *interp) doDeltaP(offset int) { + count := it.pop() + if it.err != nil { + return + } + for i := int32(0); i < count; i++ { + idx := int(it.pop()) + arg := it.pop() + if it.err != nil { + return + } + ppemEx := it.deltaBase + offset + int((arg>>4)&0x0F) + if it.ppem != ppemEx { + continue + } + p := it.pt(it.zp0, idx) + if it.err != nil { + return + } + it.moveBy(p, it.deltaStep(arg&0x0F)) + } +} + +func (it *interp) doDeltaC(offset int) { + count := it.pop() + if it.err != nil { + return + } + for i := int32(0); i < count; i++ { + cvtIdx := it.pop() + arg := it.pop() + if it.err != nil { + return + } + ppemEx := it.deltaBase + offset + int((arg>>4)&0x0F) + if it.ppem != ppemEx { + continue + } + cur := it.readCVT(cvtIdx) + if it.err != nil { + return + } + it.writeCVT(cvtIdx, cur+int32(math.Round(it.deltaStep(arg&0x0F)))) + } +} + +// doGetInfo answers the common GETINFO selectors: bit 0 requests the +// rasteriser version (reported as 42, the classic value); bit 5 (0x20) asks +// whether grayscale rendering is active, which this engine always reports true. +func (it *interp) doGetInfo() { + sel := it.pop() + if it.err != nil { + return + } + var res int32 + if sel&0x0001 != 0 { + res |= 42 + } + if sel&0x0020 != 0 { + res |= 1 << 12 // grayscale active + } + it.push(res) +} + +// --- vector / measurement opcodes ------------------------------------------- + +// f2dot14 converts a float unit-vector component to the on-wire F2Dot14 form. +func f2dot14(v float64) int32 { return int32(math.Round(v * 16384)) } + +// normVec returns the unit vector along (x,y), or the x-axis when (x,y) is zero +// (matching FreeType's fallback for a degenerate line). +func normVec(x, y float64) vec { + l := math.Hypot(x, y) + if l == 0 { + return vec{1, 0} + } + return vec{x / l, y / l} +} + +// lineVec builds a unit vector from an integer line delta, rotated 90° when +// perp is set (SxVTL[1] asks for the vector perpendicular to the line). +func lineVec(dx, dy int32, perp bool) vec { + fx, fy := float64(dx), float64(dy) + if perp { + fx, fy = -fy, fx + } + return normVec(fx, fy) +} + +// doSxVTL sets the projection (setFree false) or freedom (setFree true) vector +// to the line through two points, or its perpendicular. SPVTL also aligns the +// dual vector. +func (it *interp) doSxVTL(perp, setFree bool) { + p1 := int(it.pop()) // top operand, taken from zp2 + p2 := int(it.pop()) // deeper operand, taken from zp1 + a := it.pt(it.zp2, p1) + b := it.pt(it.zp1, p2) + if it.err != nil { + return + } + v := lineVec(b.curX-a.curX, b.curY-a.curY, perp) + if setFree { + it.fv = v + } else { + it.pv, it.dv = v, v + } +} + +// doSDPVTL sets the dual projection vector from the original outline and the +// projection vector from the current outline of the same line. +func (it *interp) doSDPVTL(perp bool) { + p1 := int(it.pop()) + p2 := int(it.pop()) + a := it.pt(it.zp2, p1) + b := it.pt(it.zp1, p2) + if it.err != nil { + return + } + it.dv = lineVec(b.origX-a.origX, b.origY-a.origY, perp) + it.pv = lineVec(b.curX-a.curX, b.curY-a.curY, perp) +} + +// doSVFS sets the projection (proj true) or freedom vector from two F2Dot14 +// stack components. SPVFS also aligns the dual vector. +func (it *interp) doSVFS(proj bool) { + y := it.pop() + x := it.pop() + if it.err != nil { + return + } + v := normVec(float64(x)/16384, float64(y)/16384) + if proj { + it.pv, it.dv = v, v + } else { + it.fv = v + } +} + +// doMD measures the distance between two points, in the current outline when +// useCur is set (MD[1], opcode 0x49) or the original outline otherwise +// (MD[0], opcode 0x4A). +func (it *interp) doMD(useCur bool) { + p1i := int(it.pop()) // top operand, from zp1 + p2i := int(it.pop()) // deeper operand, from zp0 + p1 := it.pt(it.zp1, p1i) + p2 := it.pt(it.zp0, p2i) + if it.err != nil { + return + } + var d float64 + if useCur { + d = it.project(p2.curX-p1.curX, p2.curY-p1.curY) + } else { + d = it.dualProject(p2.origX-p1.origX, p2.origY-p1.origY) + } + it.push(int32(math.Round(d))) +} + +// doMSIRP moves a point so its distance from rp0 equals a stack value. When the +// point is in the twilight zone it is first seeded at rp0's position. bit 0 of +// the opcode additionally sets rp0 to the moved point. +func (it *interp) doMSIRP(setRP0 bool) { + d := it.pop() // top operand: target distance in F26Dot6 + idx := int(it.pop()) // the point to move + ref := it.pt(it.zp0, it.rp0) + p := it.pt(it.zp1, idx) + if it.err != nil { + return + } + if it.zp1 == 0 { // twilight point: seed it at the reference position + p.origX, p.origY = ref.origX, ref.origY + p.curX, p.curY = ref.curX, ref.curY + } + cur := it.project(p.curX-ref.curX, p.curY-ref.curY) + it.moveBy(p, float64(d)-cur) + it.rp1, it.rp2 = it.rp0, idx + if setRP0 { + it.rp0 = idx + } +} + +// doISECT moves point p (zp2) to the intersection of line a0-a1 (zp1) and line +// b0-b1 (zp0). Near-parallel lines fall back to the average of the endpoints. +func (it *interp) doISECT() { + b1 := int(it.pop()) + b0 := int(it.pop()) + a1 := int(it.pop()) + a0 := int(it.pop()) + pIdx := int(it.pop()) + pa0 := it.pt(it.zp1, a0) + pa1 := it.pt(it.zp1, a1) + pb0 := it.pt(it.zp0, b0) + pb1 := it.pt(it.zp0, b1) + p := it.pt(it.zp2, pIdx) + if it.err != nil { + return + } + dax, day := float64(pa1.curX-pa0.curX), float64(pa1.curY-pa0.curY) + dbx, dby := float64(pb1.curX-pb0.curX), float64(pb1.curY-pb0.curY) + dx, dy := float64(pb0.curX-pa0.curX), float64(pb0.curY-pa0.curY) + disc := day*dbx - dax*dby + dot := dax*dbx + day*dby + if math.Abs(disc)*19 > math.Abs(dot) { + t := (dy*dbx - dx*dby) / disc + p.curX = pa0.curX + int32(math.Round(t*dax)) + p.curY = pa0.curY + int32(math.Round(t*day)) + } else { // parallel (or coincident): use the endpoint average + p.curX = (pa0.curX + pa1.curX + pb0.curX + pb1.curX) / 4 + p.curY = (pa0.curY + pa1.curY + pb0.curY + pb1.curY) / 4 + } + p.tx, p.ty = true, true +} + +// doALIGNPTS moves two points (p1 in zp1, p2 in zp0) to the midpoint of their +// projected distance. +func (it *interp) doALIGNPTS() { + p2i := int(it.pop()) // top operand, from zp0 + p1i := int(it.pop()) // deeper operand, from zp1 + p1 := it.pt(it.zp1, p1i) + p2 := it.pt(it.zp0, p2i) + if it.err != nil { + return + } + d := (it.project(p2.curX-p1.curX, p2.curY-p1.curY)) / 2 + it.moveBy(p1, d) + it.moveBy(p2, -d) +} + +// doUTP clears the touched flags of a point on the axes the freedom vector +// spans, so a subsequent IUP will interpolate it again. +func (it *interp) doUTP() { + idx := int(it.pop()) + p := it.pt(it.zp0, idx) + if it.err != nil { + return + } + if it.fv.x != 0 { + p.tx = false + } + if it.fv.y != 0 { + p.ty = false + } +} + +// shiftAmount returns the projected movement of the reference point selected by +// the SHP/SHC/SHZ opcode bit (rp1 in zp0 when useRP1, else rp2 in zp1). +func (it *interp) shiftAmount(useRP1 bool) (float64, int, int, bool) { + zp, rp := it.zp1, it.rp2 + if useRP1 { + zp, rp = it.zp0, it.rp1 + } + ref := it.pt(zp, rp) + if it.err != nil { + return 0, 0, 0, false + } + return it.project(ref.curX, ref.curY) - it.dualProject(ref.origX, ref.origY), zp, rp, true +} + +// doSHC shifts every point of the (single) contour in zp2 by the reference +// point's movement, leaving the reference point itself untouched. The contour +// index is consumed but, per this interpreter's single-contour model, ignored. +func (it *interp) doSHC(useRP1 bool) { + it.pop() // contour index (single-contour model: consumed, not used) + dp, zp, rp, ok := it.shiftAmount(useRP1) + if !ok { + return + } + z := it.zone(it.zp2) + for i := range z { + if it.zp2 == zp && i == rp { + continue // do not move the reference point twice + } + it.moveBy(&z[i], dp) + } +} + +// doSHZ shifts every point of the zone named on the stack by the reference +// point's movement. +func (it *interp) doSHZ(useRP1 bool) { + e := int(it.pop()) + dp, _, _, ok := it.shiftAmount(useRP1) + if !ok { + return + } + if e != 0 && e != 1 { + it.setErr(fmt.Errorf("opentype: hint: bad zone pointer %d", e)) + return + } + z := it.zone(e) + for i := range z { + it.moveBy(&z[i], dp) + } +} + +// doFLIPPT toggles the on/off-curve status of loop points read from the stack. +func (it *interp) doFLIPPT() { + n := it.loop + it.loop = 1 + for i := 0; i < n; i++ { + idx := int(it.pop()) + p := it.pt(it.zp0, idx) + if it.err != nil { + return + } + p.on = !p.on + } +} + +// doFlipRange sets the on-curve status of a contiguous point range in zp0. +func (it *interp) doFlipRange(on bool) { + high := int(it.pop()) + low := int(it.pop()) + if it.err != nil { + return + } + z := it.zone(it.zp0) + if low < 0 || high >= len(z) || low > high { + it.setErr(fmt.Errorf("opentype: hint: flip range %d..%d out of range", low, high)) + return + } + for i := low; i <= high; i++ { + z[i].on = on + } +} + +// doINSTCTRL records the INSTCTRL grid-fit control flags. The selector chooses +// which flag bit the value sets or clears; unknown selectors are ignored. +func (it *interp) doINSTCTRL() { + sel := it.pop() // top operand: selector + val := it.pop() // deeper operand: value + if it.err != nil { + return + } + if sel >= 1 && sel <= 2 { + mask := int32(sel) + if val != 0 { + it.instructControl |= mask + } else { + it.instructControl &^= mask + } + } +} + +// --- small numeric helpers -------------------------------------------------- + +func boolI(b bool) int32 { + if b { + return 1 + } + return 0 +} + +func maxI32(a, b int32) int32 { + if a > b { + return a + } + return b +} + +func minI32(a, b int32) int32 { + if a < b { + return a + } + return b +} diff --git a/vendor/github.com/go-opentype/opentype/instance.go b/vendor/github.com/go-opentype/opentype/instance.go new file mode 100644 index 0000000..3de6450 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/instance.go @@ -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)} + } +} diff --git a/vendor/github.com/go-opentype/opentype/kern.go b/vendor/github.com/go-opentype/opentype/kern.go new file mode 100644 index 0000000..8aacd5b --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/kern.go @@ -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)] +} diff --git a/vendor/github.com/go-opentype/opentype/layout.go b/vendor/github.com/go-opentype/opentype/layout.go new file mode 100644 index 0000000..3ef9512 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/layout.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/math.go b/vendor/github.com/go-opentype/opentype/math.go new file mode 100644 index 0000000..5eb8bc6 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/math.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/metricvar.go b/vendor/github.com/go-opentype/opentype/metricvar.go new file mode 100644 index 0000000..191cfe0 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/metricvar.go @@ -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] +} diff --git a/vendor/github.com/go-opentype/opentype/positional.go b/vendor/github.com/go-opentype/opentype/positional.go new file mode 100644 index 0000000..323bb43 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/positional.go @@ -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...) +} diff --git a/vendor/github.com/go-opentype/opentype/raster.go b/vendor/github.com/go-opentype/opentype/raster.go new file mode 100644 index 0000000..46ead01 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/raster.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/rawtable.go b/vendor/github.com/go-opentype/opentype/rawtable.go new file mode 100644 index 0000000..2e2680f --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/rawtable.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/reader.go b/vendor/github.com/go-opentype/opentype/reader.go new file mode 100644 index 0000000..db47021 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/reader.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/sfntbuild.go b/vendor/github.com/go-opentype/opentype/sfntbuild.go new file mode 100644 index 0000000..8d86adb --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/sfntbuild.go @@ -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...) +} diff --git a/vendor/github.com/go-opentype/opentype/shape.go b/vendor/github.com/go-opentype/opentype/shape.go new file mode 100644 index 0000000..82d6605 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/shape.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/subset.go b/vendor/github.com/go-opentype/opentype/subset.go new file mode 100644 index 0000000..5385be7 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/subset.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/subsetcff.go b/vendor/github.com/go-opentype/opentype/subsetcff.go new file mode 100644 index 0000000..8df413c --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/subsetcff.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/tables.go b/vendor/github.com/go-opentype/opentype/tables.go new file mode 100644 index 0000000..1e1f773 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/tables.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/type1.go b/vendor/github.com/go-opentype/opentype/type1.go new file mode 100644 index 0000000..e063733 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/type1.go @@ -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 +} diff --git a/vendor/github.com/go-opentype/opentype/type1exec.go b/vendor/github.com/go-opentype/opentype/type1exec.go new file mode 100644 index 0000000..c4ab360 --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/type1exec.go @@ -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 + } +} diff --git a/vendor/github.com/go-opentype/opentype/vertical.go b/vendor/github.com/go-opentype/opentype/vertical.go new file mode 100644 index 0000000..84629ba --- /dev/null +++ b/vendor/github.com/go-opentype/opentype/vertical.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/extract/.gitignore b/vendor/github.com/go-pdfkit/extract/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/vendor/github.com/go-pdfkit/extract/LICENSE b/vendor/github.com/go-pdfkit/extract/LICENSE new file mode 100644 index 0000000..5288bf7 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/LICENSE @@ -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. diff --git a/vendor/github.com/go-pdfkit/extract/README.md b/vendor/github.com/go-pdfkit/extract/README.md new file mode 100644 index 0000000..ae653c3 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/README.md @@ -0,0 +1,91 @@ +# extract + +[![CI](https://github.com/go-pdfkit/extract/actions/workflows/ci.yml/badge.svg)](https://github.com/go-pdfkit/extract/actions/workflows/ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/go-pdfkit/extract.svg)](https://pkg.go.dev/github.com/go-pdfkit/extract) +[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](LICENSE) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#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. diff --git a/vendor/github.com/go-pdfkit/extract/extract.go b/vendor/github.com/go-pdfkit/extract/extract.go new file mode 100644 index 0000000..64951f1 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/extract.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/extract/image.go b/vendor/github.com/go-pdfkit/extract/image.go new file mode 100644 index 0000000..7886f73 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/image.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/extract/program.go b/vendor/github.com/go-pdfkit/extract/program.go new file mode 100644 index 0000000..9ad68d3 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/program.go @@ -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) +} diff --git a/vendor/github.com/go-pdfkit/extract/renovate.json b/vendor/github.com/go-pdfkit/extract/renovate.json new file mode 100644 index 0000000..fd6bb9b --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>go-pdfkit/.github"] +} diff --git a/vendor/github.com/go-pdfkit/extract/run.go b/vendor/github.com/go-pdfkit/extract/run.go new file mode 100644 index 0000000..5e47908 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/run.go @@ -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) +} diff --git a/vendor/github.com/go-pdfkit/extract/state.go b/vendor/github.com/go-pdfkit/extract/state.go new file mode 100644 index 0000000..4ba7fb7 --- /dev/null +++ b/vendor/github.com/go-pdfkit/extract/state.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/pdffont/.gitignore b/vendor/github.com/go-pdfkit/pdffont/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/vendor/github.com/go-pdfkit/pdffont/LICENSE b/vendor/github.com/go-pdfkit/pdffont/LICENSE new file mode 100644 index 0000000..5288bf7 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/LICENSE @@ -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. diff --git a/vendor/github.com/go-pdfkit/pdffont/README.md b/vendor/github.com/go-pdfkit/pdffont/README.md new file mode 100644 index 0000000..a6599b8 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/README.md @@ -0,0 +1,75 @@ +# pdffont + +[![CI](https://github.com/go-pdfkit/pdffont/actions/workflows/ci.yml/badge.svg)](https://github.com/go-pdfkit/pdffont/actions/workflows/ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/go-pdfkit/pdffont.svg)](https://pkg.go.dev/github.com/go-pdfkit/pdffont) +[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](LICENSE) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#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. diff --git a/vendor/github.com/go-pdfkit/pdffont/encoding.go b/vendor/github.com/go-pdfkit/pdffont/encoding.go new file mode 100644 index 0000000..7c839fe --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/encoding.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/pdffont/extended.go b/vendor/github.com/go-pdfkit/pdffont/extended.go new file mode 100644 index 0000000..5f3be21 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/extended.go @@ -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": 'ț', +} diff --git a/vendor/github.com/go-pdfkit/pdffont/font.go b/vendor/github.com/go-pdfkit/pdffont/font.go new file mode 100644 index 0000000..885c11c --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/font.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/pdffont/greek.go b/vendor/github.com/go-pdfkit/pdffont/greek.go new file mode 100644 index 0000000..5fe209e --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/greek.go @@ -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": '♦', +} diff --git a/vendor/github.com/go-pdfkit/pdffont/read.go b/vendor/github.com/go-pdfkit/pdffont/read.go new file mode 100644 index 0000000..bc4cdf0 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/read.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/pdffont/tounicode.go b/vendor/github.com/go-pdfkit/pdffont/tounicode.go new file mode 100644 index 0000000..7691038 --- /dev/null +++ b/vendor/github.com/go-pdfkit/pdffont/tounicode.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/reader/.gitignore b/vendor/github.com/go-pdfkit/reader/.gitignore new file mode 100644 index 0000000..82bb7bf --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/.gitignore @@ -0,0 +1,3 @@ +cov.out +coverage.out +*.pdf diff --git a/vendor/github.com/go-pdfkit/reader/LICENSE b/vendor/github.com/go-pdfkit/reader/LICENSE new file mode 100644 index 0000000..0111c89 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/LICENSE @@ -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. diff --git a/vendor/github.com/go-pdfkit/reader/README.md b/vendor/github.com/go-pdfkit/reader/README.md new file mode 100644 index 0000000..c10abea --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/README.md @@ -0,0 +1,144 @@ +# reader + +[![CI](https://github.com/go-pdfkit/reader/actions/workflows/ci.yml/badge.svg)](https://github.com/go-pdfkit/reader/actions/workflows/ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/go-pdfkit/reader.svg)](https://pkg.go.dev/github.com/go-pdfkit/reader) +[![Go Report Card](https://goreportcard.com/badge/github.com/go-pdfkit/reader)](https://goreportcard.com/report/github.com/go-pdfkit/reader) +[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](LICENSE) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#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. diff --git a/vendor/github.com/go-pdfkit/reader/ccitt.go b/vendor/github.com/go-pdfkit/reader/ccitt.go new file mode 100644 index 0000000..386d032 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/ccitt.go @@ -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 + } +} diff --git a/vendor/github.com/go-pdfkit/reader/content.go b/vendor/github.com/go-pdfkit/reader/content.go new file mode 100644 index 0000000..5b8b5e4 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/content.go @@ -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 +} diff --git a/vendor/github.com/go-pdfkit/reader/crypt.go b/vendor/github.com/go-pdfkit/reader/crypt.go new file mode 100644 index 0000000..264288e --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/crypt.go @@ -0,0 +1,572 @@ +package reader + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rc4" + "crypto/sha256" + "crypto/sha512" + "errors" + "fmt" + "hash" +) + +// ErrWrongPassword is returned when a file is encrypted and neither the +// password given nor the empty one opens it. +var ErrWrongPassword = errors.New("reader: the password does not open this file") + +// ErrUnsupportedEncryption is returned for a security handler this package +// does not implement — anything other than the standard one. +var ErrUnsupportedEncryption = errors.New("reader: unsupported security handler") + +// pad is the 32-byte string every pre-2.0 password is padded with. +var pad = []byte{ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, + 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, + 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, +} + +// cryptMethod is how one class of data — strings or streams — is protected. +type cryptMethod uint8 + +const ( + cryptNone cryptMethod = iota // /Identity: not encrypted at all + cryptRC4 + cryptAESV2 // 128-bit AES, per-object key + cryptAESV3 // 256-bit AES, the file key used directly +) + +// A decryptor holds the file encryption key and how to apply it. +type decryptor struct { + key []byte + strings cryptMethod + streams cryptMethod + revision int + perm Permissions + owner bool // the password given was the owner's, not the user's + skipObj int // the /Encrypt dictionary's own object number, never decrypted + skipKnown bool // whether skipObj is meaningful +} + +// A Protection is what a file's security handler says about it: how it is +// encrypted, and what a reader that opened it with the user password may do. +type Protection struct { + // Method names the algorithm the way a person would say it: "RC4-40", + // "RC4-128", "AES-128", "AES-256", or "none" for a file that declares an + // /Encrypt dictionary and then encrypts nothing with it. + Method string + // Revision is the standard security handler's revision: 2, 3, 4, 5 or 6. + Revision int + // Permissions is what the file grants whoever opened it with the user + // password. + Permissions Permissions + // Owner is true when the password the file was opened with was the + // owner's, in which case the permissions do not apply to this reader. + Owner bool +} + +// Protection reports how the file is protected, and false when it is not +// protected at all. A document that opened has already been decrypted; this +// says what it said about itself on the way. +func (d *Document) Protection() (Protection, bool) { + if d.decrypt == nil { + return Protection{}, false + } + return Protection{ + Method: d.decrypt.methodName(), + Revision: d.decrypt.revision, + Permissions: d.decrypt.perm, + Owner: d.decrypt.owner, + }, true +} + +// methodName says which algorithm protects the streams, which is the one that +// matters: it is where the content is. +func (dec *decryptor) methodName() string { + switch dec.streams { + case cryptAESV3: + return "AES-256" + case cryptAESV2: + return "AES-128" + case cryptRC4: + return fmt.Sprintf("RC4-%d", len(dec.key)*8) + } + return "none" +} + +// Encrypted reports whether the file declares an /Encrypt dictionary. +func (d *Document) Encrypted() bool { + return d.trailer != nil && d.trailer.Get("Encrypt").Kind() != KindNull +} + +// setUpDecryption reads /Encrypt and derives the file key. It is called once, +// before any string or stream is handed out. +func (d *Document) setUpDecryption(password string) error { + if !d.Encrypted() { + return nil + } + if ref, ok := d.trailer.Get("Encrypt").(Ref); ok { + d.encryptNum, d.encryptKnown = ref.Num, true + } + o, err := d.Resolve(d.trailer.Get("Encrypt")) + if err != nil { + return err + } + enc, ok := ToDict(o) + if !ok { + return fmt.Errorf("reader: /Encrypt is a %s, not a dictionary", o.Kind()) + } + id := d.firstID() + dec, err := newDecryptor(enc, id, password, d.Get) + if err != nil { + return err + } + dec.skipObj, dec.skipKnown = d.encryptNum, d.encryptKnown + d.decrypt = dec + return nil +} + +// firstID returns the first element of the trailer's /ID, which takes part in +// the pre-2.0 key derivation. A file without one contributes nothing. +func (d *Document) firstID() []byte { + arr, ok := ToArray(d.trailer.Get("ID")) + if !ok || len(arr) == 0 { + return nil + } + s, ok := ToString(arr[0]) + if !ok { + return nil + } + return s +} + +// newDecryptor derives the file encryption key from a password. +func newDecryptor(enc Dict, id []byte, password string, r Resolver) (*decryptor, error) { + if f, ok := ToName(resolved(enc, "Filter", r)); ok && f != "Standard" { + return nil, ErrUnsupportedEncryption + } + v := int(intOf(resolved(enc, "V", r), 0)) + rev := int(intOf(resolved(enc, "R", r), 0)) + if v == 0 || rev == 0 { + return nil, fmt.Errorf("reader: /Encrypt has no usable /V and /R") + } + length := int(intOf(resolved(enc, "Length", r), 40)) + if length < 40 || length > 256 || length%8 != 0 { + length = 40 + } + dec := &decryptor{revision: rev} + if err := dec.readMethods(enc, v, r); err != nil { + return nil, err + } + owner, _ := ToString(resolved(enc, "O", r)) + user, _ := ToString(resolved(enc, "U", r)) + perm := int32(intOf(resolved(enc, "P", r), 0)) + metadata := true + if b, ok := ToBool(resolved(enc, "EncryptMetadata", r)); ok { + metadata = b + } + + dec.perm = Permissions(uint32(perm)) & AllPermissions + if dec.streams == cryptNone && dec.strings == cryptNone { + // /StmF and /StrF both name the identity crypt filter, so nothing in + // the document body is encrypted and no key is needed to read it. A + // file like that is opened without validating the password: refusing + // it would refuse a document whose every byte is already plain, which + // is what every other reader shows. Only /EFF — the embedded files — + // would need the key, and this reader does not hand those out. + // + // Nothing was authenticated, so dec.owner stays false and Protection + // reports the method as "none": a caller can see exactly what it got. + return dec, nil + } + if rev >= 5 { + key, asOwner, err := deriveKeyR5(enc, password, r) + if err != nil { + return nil, err + } + dec.key, dec.owner = key, asOwner + return dec, nil + } + n := length / 8 + if rev == 2 { + n = 5 + } + key, asOwner, err := deriveKeyLegacy(password, owner, user, id, perm, n, rev, metadata) + if err != nil { + return nil, err + } + dec.key, dec.owner = key, asOwner + return dec, nil +} + +// readMethods works out how strings and streams are protected. Before /V 4 +// there is one method for everything; from /V 4 the crypt filters in /CF are +// named by /StmF and /StrF. +func (dec *decryptor) readMethods(enc Dict, v int, r Resolver) error { + if v < 4 { + dec.strings, dec.streams = cryptRC4, cryptRC4 + return nil + } + cf, _ := ToDict(resolved(enc, "CF", r)) + pick := func(key Name) (cryptMethod, error) { + name, ok := ToName(resolved(enc, key, r)) + if !ok || name == "Identity" { + return cryptNone, nil + } + f, ok := ToDict(resolved(cf, name, r)) + if !ok { + return cryptNone, nil + } + switch m, _ := ToName(resolved(f, "CFM", r)); m { + case "V2": + return cryptRC4, nil + case "AESV2": + return cryptAESV2, nil + case "AESV3": + return cryptAESV3, nil + case "None": + return cryptNone, nil + default: + return cryptNone, fmt.Errorf("reader: unsupported crypt filter method /%s", m) + } + } + var err error + if dec.streams, err = pick("StmF"); err != nil { + return err + } + dec.strings, err = pick("StrF") + return err +} + +// deriveKeyLegacy is the pre-2.0 key derivation, trying the password as the +// user password and then as the owner password. +func deriveKeyLegacy(password string, owner, user, id []byte, perm int32, n, rev int, metadata bool) (key []byte, asOwner bool, err error) { + for _, candidate := range legacyCandidates(password, owner, n, rev) { + k := legacyFileKey(candidate.padded, owner, id, perm, n, rev, metadata) + if legacyUserKeyMatches(k, user, id, rev) { + return k, candidate.owner, nil + } + } + return nil, false, ErrWrongPassword +} + +// A legacyCandidate is one padded password to try, and whether reaching it +// meant knowing the owner's rather than the user's. +type legacyCandidate struct { + padded []byte + owner bool +} + +// legacyCandidates lists the padded passwords worth trying: the one given, the +// empty one, and the user password the owner password unlocks. +func legacyCandidates(password string, owner []byte, n, rev int) []legacyCandidate { + out := []legacyCandidate{{padded: padPassword([]byte(password))}} + if password != "" { + out = append(out, legacyCandidate{padded: padPassword(nil)}) + } + if u := userFromOwner([]byte(password), owner, n, rev); u != nil { + out = append(out, legacyCandidate{padded: u, owner: true}) + } + return out +} + +// padPassword truncates or pads a password to the 32 bytes the algorithm wants. +func padPassword(p []byte) []byte { + out := make([]byte, 32) + k := copy(out, p) + copy(out[k:], pad) + return out +} + +// legacyFileKey is algorithm 2: the file encryption key from a padded password. +func legacyFileKey(padded, owner, id []byte, perm int32, n, rev int, metadata bool) []byte { + h := md5.New() + h.Write(padded) + h.Write(owner) + h.Write([]byte{byte(perm), byte(perm >> 8), byte(perm >> 16), byte(perm >> 24)}) + h.Write(id) + if rev >= 4 && !metadata { + h.Write([]byte{0xFF, 0xFF, 0xFF, 0xFF}) + } + key := h.Sum(nil) + if rev >= 3 { + for i := 0; i < 50; i++ { + sum := md5.Sum(key[:n]) + key = sum[:] + } + } + return key[:n] +} + +// legacyUserKeyMatches is algorithm 6: does this key reproduce /U? +func legacyUserKeyMatches(key, user, id []byte, rev int) bool { + if rev == 2 { + return bytes.Equal(rc4Bytes(key, pad), user) + } + h := md5.New() + h.Write(pad) + h.Write(id) + x := rc4Bytes(key, h.Sum(nil)) + for i := 1; i <= 19; i++ { + x = rc4Bytes(xorKey(key, i), x) + } + return len(user) >= 16 && bytes.Equal(x, user[:16]) +} + +// userFromOwner is algorithm 7: recover the padded user password from /O using +// the owner password. +func userFromOwner(password, owner []byte, n, rev int) []byte { + if len(owner) < 32 { + return nil + } + sum := md5.Sum(padPassword(password)) + key := sum[:] + if rev >= 3 { + for i := 0; i < 50; i++ { + s := md5.Sum(key) + key = s[:] + } + } + key = key[:n] + if rev == 2 { + return rc4Bytes(key, owner[:32]) + } + x := owner[:32] + for i := 19; i >= 0; i-- { + x = rc4Bytes(xorKey(key, i), x) + } + return x +} + +// xorKey returns key with every byte exclusive-ored with v, which is how both +// password algorithms walk their nineteen rounds. +func xorKey(key []byte, v int) []byte { + out := make([]byte, len(key)) + for i := range key { + out[i] = key[i] ^ byte(v) + } + return out +} + +// deriveKeyR5 is the PDF 2.0 derivation, /R 5 and /R 6: the password is +// validated against a salted hash and then unwraps the file key. +func deriveKeyR5(enc Dict, password string, r Resolver) (key []byte, asOwner bool, err error) { + user, _ := ToString(resolved(enc, "U", r)) + userE, _ := ToString(resolved(enc, "UE", r)) + owner, _ := ToString(resolved(enc, "O", r)) + ownerE, _ := ToString(resolved(enc, "OE", r)) + rev := int(intOf(resolved(enc, "R", r), 6)) + if len(user) < 48 { + return nil, false, fmt.Errorf("reader: /U is %d bytes, not the 48 this revision needs", len(user)) + } + pw := []byte(password) + for _, candidate := range [][]byte{pw, nil} { + if candidate == nil && password == "" { + break + } + if key := unlockR5(candidate, user, userE, nil, rev); key != nil { + return key, false, nil + } + if len(owner) >= 48 { + if key := unlockR5(candidate, owner, ownerE, user[:48], rev); key != nil { + return key, true, nil + } + } + } + return nil, false, ErrWrongPassword +} + +// unlockR5 checks one password against a 48-byte /U or /O entry and, when it +// matches, unwraps the file key from the matching /UE or /OE. +func unlockR5(password, entry, wrapped, udata []byte, rev int) []byte { + valSalt, keySalt := entry[32:40], entry[40:48] + if !bytes.Equal(hash2B(password, valSalt, udata, rev), entry[:32]) { + return nil + } + if len(wrapped) < 32 { + return nil + } + inter := hash2B(password, keySalt, udata, rev) + // inter is always the 32 bytes hash2B returns, so the cipher cannot refuse it. + block, _ := aes.NewCipher(inter) + out := make([]byte, 32) + cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(out, wrapped[:32]) + return out +} + +// hash2B is algorithm 2.B: SHA-256 for revision 5, and for revision 6 the +// hardening loop that repeatedly encrypts and re-hashes. +func hash2B(password, salt, udata []byte, rev int) []byte { + h := sha256.New() + h.Write(password) + h.Write(salt) + h.Write(udata) + k := h.Sum(nil) + if rev < 6 { + return k + } + for round := 0; ; round++ { + var k1 []byte + one := append(append(append([]byte{}, password...), k...), udata...) + for i := 0; i < 64; i++ { + k1 = append(k1, one...) + } + // k is always 32 bytes, so a 16-byte key is always well formed. + block, _ := aes.NewCipher(k[:16]) + e := make([]byte, len(k1)-len(k1)%aes.BlockSize) + cipher.NewCBCEncrypter(block, k[16:32]).CryptBlocks(e, k1[:len(e)]) + sum := 0 + for _, b := range e[:16] { + sum += int(b) + } + var next hash.Hash + switch sum % 3 { + case 0: + next = sha256.New() + case 1: + next = sha512.New384() + default: + next = sha512.New() + } + next.Write(e) + k = next.Sum(nil) + if round >= 63 && int(e[len(e)-1]) <= round-31 { + break + } + } + return k[:32] +} + +// objectKey is the per-object key the pre-2.0 methods use; AESV3 uses the file +// key unchanged. +func (dec *decryptor) objectKey(num, gen int, method cryptMethod) []byte { + if method == cryptAESV3 { + return dec.key + } + h := md5.New() + h.Write(dec.key) + h.Write([]byte{byte(num), byte(num >> 8), byte(num >> 16), byte(gen), byte(gen >> 8)}) + if method == cryptAESV2 { + h.Write([]byte{0x73, 0x41, 0x6C, 0x54}) // "sAlT" + } + k := h.Sum(nil) + if n := len(dec.key) + 5; n < 16 { + return k[:n] + } + return k +} + +// decryptBytes undoes one method over one object's data. +func (dec *decryptor) decryptBytes(num, gen int, method cryptMethod, data []byte) []byte { + switch method { + case cryptNone: + return data + case cryptRC4: + return rc4Bytes(dec.objectKey(num, gen, method), data) + default: + return aesDecrypt(dec.objectKey(num, gen, method), data) + } +} + +// rc4Bytes applies RC4, which is its own inverse. A key length the cipher +// refuses yields nil, which every caller reads as a password that does not +// fit — the derivations here never produce one. +func rc4Bytes(key, data []byte) []byte { + c, err := rc4.NewCipher(key) + if err != nil { + return nil + } + out := make([]byte, len(data)) + c.XORKeyStream(out, data) + return out +} + +// aesDecrypt undoes AES in CBC mode, the initialisation vector being the first +// block of the data and the padding the one CBC calls for. +func aesDecrypt(key, data []byte) []byte { + if len(data) < 2*aes.BlockSize || len(data)%aes.BlockSize != 0 { + return nil + } + block, err := aes.NewCipher(key) + if err != nil { + return nil + } + out := make([]byte, len(data)-aes.BlockSize) + cipher.NewCBCDecrypter(block, data[:aes.BlockSize]).CryptBlocks(out, data[aes.BlockSize:]) + n := int(out[len(out)-1]) + if n < 1 || n > aes.BlockSize || n > len(out) { + return out + } + return out[:len(out)-n] +} + +// decryptObject walks an object, decrypting every string in it and, for a +// stream, its data. The /Encrypt dictionary itself, and any cross-reference +// stream, are left alone: neither is encrypted. +func (dec *decryptor) decryptObject(num, gen int, o Object) Object { + if dec.skipKnown && num == dec.skipObj { + return o + } + return dec.walk(num, gen, o) +} + +// walk is decryptObject's recursion. +func (dec *decryptor) walk(num, gen int, o Object) Object { + switch v := o.(type) { + case String: + return String(dec.decryptBytes(num, gen, dec.strings, v)) + case Array: + for i, e := range v { + v[i] = dec.walk(num, gen, e) + } + return v + case Dict: + for k, e := range v { + v[k] = dec.walk(num, gen, e) + } + return v + case *Stream: + if t, ok := ToName(v.Dict.Get("Type")); ok && t == "XRef" { + return v + } + v.Dict, _ = ToDict(dec.walk(num, gen, v.Dict)) + if streamIsPlain(v.Dict) { + return v + } + v.Raw = dec.decryptBytes(num, gen, dec.streams, v.Raw) + return v + } + return o +} + +// streamIsPlain reports whether a stream's own filter chain says its bytes were +// left unencrypted: a leading /Crypt filter naming /Identity, which is what +// /Crypt with no /Name means too. Producers use it for the metadata stream of a +// file whose /EncryptMetadata is false, and decrypting such a stream turns +// readable XML into noise. +func streamIsPlain(d Dict) bool { + if first, ok := firstFilter(d); !ok || first != "Crypt" { + return false + } + name, ok := ToName(firstDecodeParms(d).Get("Name")) + return !ok || name == "Identity" +} + +// resolved is a helper for reading an /Encrypt entry that may be indirect. +func resolved(d Dict, key Name, r Resolver) Object { + o, err := Resolve(d.Get(key), r) + if err != nil { + return Null{} + } + return o +} + +// intOf reads an integer with a default. +func intOf(o Object, def int64) int64 { + if n, ok := ToInt(o); ok { + return n + } + return def +} diff --git a/vendor/github.com/go-pdfkit/reader/document.go b/vendor/github.com/go-pdfkit/reader/document.go new file mode 100644 index 0000000..78eb20c --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/document.go @@ -0,0 +1,431 @@ +package reader + +import ( + "bytes" + "fmt" +) + +// maxPageTreeNodes bounds a page tree walk. /Count is a claim, not a fact, and +// /Kids can be made to point back at an ancestor. +const maxPageTreeNodes = 1 << 20 + +// inheritable lists the page attributes a node may take from an ancestor. +var inheritable = []Name{"Resources", "MediaBox", "CropBox", "Rotate"} + +// A Document is a parsed PDF file: its cross-reference information, its +// trailer, and lazy access to every object in it. +type Document struct { + buf []byte + xref map[int]xrefEntry + trailer Dict + cache map[int]Object + loading map[int]bool + objStms map[int]map[int]Object + pages []Ref + password string + decrypt *decryptor + encryptNum int + encryptKnown bool + repaired bool +} + +// Open parses the cross-reference information of a PDF file held in memory, +// using the empty password. A file whose tables are damaged is rebuilt by +// scanning it, so Open fails only when there is no usable document structure +// at all, or when the file is encrypted and the empty password does not open +// it — [OpenWithPassword] then does. +func Open(b []byte) (*Document, error) { return OpenWithPassword(b, "") } + +// OpenWithPassword is Open with a password to try. The password is tried both +// as the user password and as the owner password, the empty one as well. +func OpenWithPassword(b []byte, password string) (*Document, error) { + d := &Document{ + buf: b, + xref: map[int]xrefEntry{}, + cache: map[int]Object{}, + loading: map[int]bool{}, + objStms: map[int]map[int]Object{}, + password: password, + } + err := d.loadXref() + if err == nil { + if derr := d.setUpDecryption(password); derr != nil { + return nil, derr + } + d.cache = map[int]Object{} + cerr := error(nil) + if _, cerr = d.Catalog(); cerr == nil { + return d, nil + } + // Tables that parse but lead nowhere are worse than none: rebuild. + // What the catalogue lookup actually said is kept: "no page tree" and + // "/Root is a null" send a reader to different places. + err = fmt.Errorf("reader: the cross-reference tables do not lead to a catalogue: %w", cerr) + } + if rerr := d.repair(); rerr != nil { + // Both diagnoses matter, and the rebuild's is the one about the file + // as it really is: it has read every object header in it, where the + // tables only failed to be read. Returning the tables' error alone + // reported "no startxref" for a file that turned out to hold no + // objects at all, or an encrypted one whose catalogue could not be + // reached — a different problem with a different answer. + return nil, fmt.Errorf("%w (the cross-reference information was unusable too: %v)", rerr, err) + } + // Setting the key up again here used to be necessary, because the rebuild + // ran before anything knew the file was encrypted. repair() establishes it + // itself now, so by this point it is either known or not needed. + return d, nil +} + +// Trailer returns the file's trailer dictionary, the newest value of every key +// across the chain of cross-reference sections. +func (d *Document) Trailer() Dict { return d.trailer } + +// Repaired reports whether the document's structure was rebuilt by scanning +// the file rather than read from its cross-reference tables. +func (d *Document) Repaired() bool { return d.repaired } + +// Resolver returns the function that turns this document's indirect references +// into objects, for [Resolve] and [Decode]. +func (d *Document) Resolver() Resolver { return d.Get } + +// Resolve follows indirect references until it reaches a direct object. +func (d *Document) Resolve(o Object) (Object, error) { return Resolve(o, d.Get) } + +// GetDict resolves an entry of a dictionary to a dictionary. +func (d *Document) GetDict(from Dict, key Name) (Dict, bool) { + o, err := d.Resolve(from.Get(key)) + if err != nil { + return nil, false + } + return ToDict(o) +} + +// Get returns the object an indirect reference names, or [Null] when the file +// does not define it. +func (d *Document) Get(ref Ref) (Object, error) { + if o, ok := d.cache[ref.Num]; ok { + return o, nil + } + e, ok := d.xref[ref.Num] + if !ok || e.kind == 'f' { + return Null{}, nil + } + if d.loading[ref.Num] { + // An object stream whose own /Length or /Filter points back at an + // object inside itself would otherwise recurse forever. + return Null{}, nil + } + d.loading[ref.Num] = true + defer delete(d.loading, ref.Num) + + var obj Object + var err error + if e.kind == 'o' { + obj, err = d.getFromObjectStream(ref.Num, e) + } else { + obj, err = d.getAtOffset(ref.Num, e) + } + if err != nil { + return nil, err + } + d.cache[ref.Num] = obj + return obj, nil +} + +// getAtOffset reads an object written directly in the file. An offset that +// does not hold the expected object number means the tables are wrong, which +// is common enough that it triggers a rebuild rather than an error. +func (d *Document) getAtOffset(num int, e xrefEntry) (Object, error) { + if e.offset < 0 || e.offset >= int64(len(d.buf)) { + return d.retryAfterRepair(num, fmt.Errorf("reader: object %d is at offset %d, outside the file", num, e.offset)) + } + got, obj, _, err := ParseIndirectObject(d.buf[e.offset:], d.Get) + if err != nil { + return d.retryAfterRepair(num, err) + } + if got.Num != num { + return d.retryAfterRepair(num, fmt.Errorf("reader: offset %d holds object %d, not %d", e.offset, got.Num, num)) + } + if d.decrypt != nil { + obj = d.decrypt.decryptObject(num, got.Gen, obj) + } + return obj, nil +} + +// retryAfterRepair rebuilds the tables once and looks the object up again. +func (d *Document) retryAfterRepair(num int, cause error) (Object, error) { + if d.repaired { + return Null{}, nil + } + if err := d.repair(); err != nil { + return nil, cause + } + e, ok := d.xref[num] + if !ok || e.kind != 'n' || e.offset >= int64(len(d.buf)) { + return Null{}, nil + } + got, obj, _, err := ParseIndirectObject(d.buf[e.offset:], d.Get) + if err != nil || got.Num != num { + return Null{}, nil + } + if d.decrypt != nil { + obj = d.decrypt.decryptObject(num, got.Gen, obj) + } + return obj, nil +} + +// getFromObjectStream reads an object held inside a /Type /ObjStm stream. +func (d *Document) getFromObjectStream(num int, e xrefEntry) (Object, error) { + objs, err := d.objectStream(e.strmNum) + if err != nil { + return nil, err + } + if o, ok := objs[num]; ok { + return o, nil + } + // The index is a hint; a stream that does not hold the object simply + // does not define it. + return Null{}, nil +} + +// objectStream parses an object stream once and keeps its contents. +func (d *Document) objectStream(num int) (map[int]Object, error) { + if objs, ok := d.objStms[num]; ok { + return objs, nil + } + objs := map[int]Object{} + d.objStms[num] = objs + + o, err := d.Get(Ref{Num: num}) + if err != nil { + return objs, err + } + s, ok := ToStream(o) + if !ok { + return objs, nil + } + // An object stream that stops in the middle still holds whole objects in + // the part that did decode, and losing them loses pages. + dec := d.DecodeStreamRecovering(s) + if dec.Image != "" { + return objs, nil + } + data := dec.Data + n := int(intOr(s.Dict.Get("N"), 0)) + first := int(intOr(s.Dict.Get("First"), 0)) + if n <= 0 || first <= 0 || first > len(data) { + return objs, nil + } + l := &lexer{buf: data[:first]} + type pair struct{ num, off int } + pairs := make([]pair, 0, n) + for i := 0; i < n; i++ { + t1, err1 := l.next() + t2, err2 := l.next() + if err1 != nil || err2 != nil || t1.kind != tokInteger || t2.kind != tokInteger { + break + } + pairs = append(pairs, pair{int(t1.i), first + int(t2.i)}) + } + for _, p := range pairs { + if p.off < 0 || p.off > len(data) { + continue + } + obj, _, err := ParseObject(data[p.off:]) + if err != nil { + continue + } + objs[p.num] = obj + } + return objs, nil +} + +// intOr resolves nothing; it reads a direct integer or returns a default. +func intOr(o Object, def int64) int64 { + if n, ok := ToInt(o); ok { + return n + } + return def +} + +// DecodeStream applies a stream's filter chain, resolving any indirect decode +// parameters against this document. A chain that cannot be run to the end is +// an error; [Document.DecodeStreamRecovering] salvages instead. +func (d *Document) DecodeStream(s *Stream) ([]byte, Name, error) { + return Decode(s.Dict, s.Raw, d.Get) +} + +// DecodeStreamRecovering applies a stream's filter chain, salvaging what it can +// from a chain that cannot be run to the end and saying that it did so. +func (d *Document) DecodeStreamRecovering(s *Stream) Decoded { + return DecodeRecovering(s.Dict, s.Raw, d.Get) +} + +// Catalog returns the document catalogue the trailer's /Root names. +func (d *Document) Catalog() (Dict, error) { + if d.trailer == nil { + return nil, fmt.Errorf("reader: the file has no trailer") + } + o, err := d.Resolve(d.trailer.Get("Root")) + if err != nil { + return nil, err + } + cat, ok := ToDict(o) + if !ok { + return nil, fmt.Errorf("reader: /Root is a %s, not a dictionary", o.Kind()) + } + if _, ok := d.GetDict(cat, "Pages"); !ok { + return nil, fmt.Errorf("reader: the catalogue has no page tree") + } + return cat, nil +} + +// PageCount reports how many pages the document has. +func (d *Document) PageCount() int { return len(d.pageRefs()) } + +// PageRef returns the reference of the i'th page, counting from one. +func (d *Document) PageRef(i int) (Ref, bool) { + refs := d.pageRefs() + if i < 1 || i > len(refs) { + return Ref{}, false + } + return refs[i-1], true +} + +// Page returns the i'th page's dictionary, counting from one, with the +// attributes it inherits from its ancestors filled in. +func (d *Document) Page(i int) (Dict, error) { + ref, ok := d.PageRef(i) + if !ok { + return nil, fmt.Errorf("reader: page %d is out of range (the document has %d)", i, d.PageCount()) + } + o, err := d.Get(ref) + if err != nil { + return nil, err + } + page, ok := ToDict(o) + if !ok { + return nil, fmt.Errorf("reader: page %d is a %s, not a dictionary", i, o.Kind()) + } + return d.withInherited(page), nil +} + +// withInherited copies the page and fills in what its ancestors provide. +func (d *Document) withInherited(page Dict) Dict { + out := Dict{} + for k, v := range page { + out[k] = v + } + node := page + for depth := 0; depth < maxRefDepth; depth++ { + missing := false + for _, k := range inheritable { + if _, ok := out[k]; !ok { + missing = true + } + } + if !missing { + break + } + parent, ok := d.GetDict(node, "Parent") + if !ok { + break + } + for _, k := range inheritable { + if _, ok := out[k]; ok { + continue + } + if v, ok := parent[k]; ok { + out[k] = v + } + } + node = parent + } + return out +} + +// pageRefs walks the page tree once, in document order. +func (d *Document) pageRefs() []Ref { + if d.pages != nil { + return d.pages + } + d.pages = []Ref{} + cat, err := d.Catalog() + if err != nil { + return d.pages + } + root := cat["Pages"] + seen := map[Ref]bool{} + d.walkPages(root, seen, 0) + if len(d.pages) == 0 { + // A tree that yields nothing but a catalogue that names one page node + // is still a one-page document. + d.pages = d.scanForPages() + } + return d.pages +} + +// walkPages appends the leaves of a page tree node in order. +func (d *Document) walkPages(node Object, seen map[Ref]bool, depth int) { + if depth > 64 || len(d.pages) >= maxPageTreeNodes { + return + } + ref, isRef := node.(Ref) + if isRef { + if seen[ref] { + return + } + seen[ref] = true + } + o, err := d.Resolve(node) + if err != nil { + return + } + dict, ok := ToDict(o) + if !ok { + return + } + kids, hasKids := d.kidsOf(dict) + if !hasKids { + if isRef { + d.pages = append(d.pages, ref) + } + return + } + for _, kid := range kids { + d.walkPages(kid, seen, depth+1) + } +} + +// kidsOf reports a node's /Kids when it is an interior node of the page tree. +func (d *Document) kidsOf(node Dict) (Array, bool) { + if t, ok := ToName(node.Get("Type")); ok && t == "Page" { + return nil, false + } + o, err := d.Resolve(node.Get("Kids")) + if err != nil { + return nil, false + } + kids, ok := ToArray(o) + return kids, ok +} + +// Version reports the PDF version the header declares, "1.7" and the like. +func (d *Document) Version() string { + i := bytes.Index(d.buf, []byte("%PDF-")) + if i < 0 || i+8 > len(d.buf) { + return "" + } + return string(bytes.TrimRight(d.buf[i+5:i+8], "\r\n \t")) +} + +// scanForPages is the last resort for a page tree that resolves to nothing: +// every object in the file that calls itself a page, in object-number order. +func (d *Document) scanForPages() []Ref { + var out []Ref + for _, num := range d.objectsOfType("Page") { + out = append(out, Ref{Num: num}) + } + return out +} diff --git a/vendor/github.com/go-pdfkit/reader/encrypt.go b/vendor/github.com/go-pdfkit/reader/encrypt.go new file mode 100644 index 0000000..613550d --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/encrypt.go @@ -0,0 +1,365 @@ +package reader + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "fmt" + "strings" +) + +// Permissions say what a reader may do with a file it can open with the user +// password. They are advisory — nothing enforces them but the reader's own +// good manners — and a file opened with the owner password ignores them +// entirely. +type Permissions uint32 + +// The permissions a standard security handler can express. Anything not +// granted is refused; [AllPermissions] grants everything. +const ( + PermPrint Permissions = 1 << 2 + PermModify Permissions = 1 << 3 + PermCopy Permissions = 1 << 4 + PermAnnotate Permissions = 1 << 5 + PermFillForms Permissions = 1 << 8 + PermExtract Permissions = 1 << 9 // for accessibility + PermAssemble Permissions = 1 << 10 + PermPrintFaithful Permissions = 1 << 11 // at full resolution + + // AllPermissions grants every one of them. + AllPermissions = PermPrint | PermModify | PermCopy | PermAnnotate | + PermFillForms | PermExtract | PermAssemble | PermPrintFaithful +) + +// permissionNames pairs each permission with what it lets a reader do, in the +// order a person would want to read them. +var permissionNames = []struct { + bit Permissions + name string +}{ + {PermPrint, "print"}, + {PermPrintFaithful, "print at full resolution"}, + {PermModify, "modify"}, + {PermAssemble, "assemble"}, + {PermCopy, "copy"}, + {PermExtract, "extract for accessibility"}, + {PermAnnotate, "annotate"}, + {PermFillForms, "fill in forms"}, +} + +// Allows reports whether every one of the given permissions is granted. +func (p Permissions) Allows(want Permissions) bool { return p&want == want } + +// String lists what is granted, in words. +func (p Permissions) String() string { + var out []string + for _, e := range permissionNames { + if p.Allows(e.bit) { + out = append(out, e.name) + } + } + if len(out) == 0 { + return "nothing" + } + return strings.Join(out, ", ") +} + +// permissionBase is the pattern of reserved bits the specification requires +// around the permissions themselves. +const permissionBase uint32 = 0xFFFFF0C0 + +// An Encryption says how a file is to be protected. +// +// Two people can open it: whoever knows the user password, subject to the +// permissions, and whoever knows the owner password, subject to nothing. An +// empty user password means the file opens without one and the permissions are +// all it says. +type Encryption struct { + UserPassword string + OwnerPassword string + Permissions Permissions + + // AES128 asks for the older method, which readers before 2008 understand. + // The default is AES-256, which is what a file should use today. + AES128 bool +} + +// Encrypt protects everything written from here on. It must be called before +// anything is written, and the file it produces is not byte-for-byte +// reproducible: encryption needs randomness, by design. +func (w *Writer) Encrypt(e Encryption) { + if len(w.offsets) > 0 || len(w.pending) > 0 { + w.note(fmt.Errorf("reader: a file cannot be encrypted after it has been written to")) + return + } + enc, err := newEncrypter(e) + if err != nil { + w.note(err) + return + } + w.encrypt = enc +} + +// An encrypter holds the file key and the dictionary that describes it. +type encrypter struct { + key []byte + method cryptMethod + dict Dict + id []byte + err error + // number is the object the /Encrypt dictionary is written under, which is + // the one thing in the file that is never encrypted. + number int +} + +// newEncrypter derives a file key and builds the dictionary that lets a reader +// derive it again from a password. +func newEncrypter(e Encryption) (*encrypter, error) { + perm := permissionBase | uint32(e.Permissions) + id, err := randomBytes(16) + if err != nil { + return nil, err + } + if e.AES128 { + return newLegacyEncrypter(e, perm, id) + } + return newAES256Encrypter(e, perm, id) +} + +// newAES256Encrypter builds the revision 6 form: a random file key, wrapped +// twice, once for each password. +func newAES256Encrypter(e Encryption, perm uint32, id []byte) (*encrypter, error) { + key, err := randomBytes(32) + if err != nil { + return nil, err + } + // An owner password nobody set is the user's: leaving it empty would + // mean anything at all opens the file with every permission, which is + // the opposite of what asking for encryption means. + ownerPassword := e.OwnerPassword + if ownerPassword == "" { + ownerPassword = e.UserPassword + } + user, userE, err := wrapKey([]byte(e.UserPassword), key, nil) + if err != nil { + return nil, err + } + owner, ownerE, err := wrapKey([]byte(ownerPassword), key, user) + if err != nil { + return nil, err + } + perms, err := permsEntry(key, perm) + if err != nil { + return nil, err + } + return &encrypter{ + key: key, + method: cryptAESV3, + id: id, + dict: Dict{ + "Filter": Name("Standard"), + "V": Integer(5), + "R": Integer(6), + "Length": Integer(256), + "P": Integer(int32(perm)), + "U": String(user), + "UE": String(userE), + "O": String(owner), + "OE": String(ownerE), + "Perms": String(perms), + "CF": Dict{"StdCF": Dict{"CFM": Name("AESV3"), "Length": Integer(32)}}, + "StmF": Name("StdCF"), + "StrF": Name("StdCF"), + "EncryptMetadata": Bool(true), + }, + }, nil +} + +// wrapKey builds one 48-byte password entry and the 32 bytes that hold the +// file key wrapped for it. +func wrapKey(password, key, udata []byte) (entry, wrapped []byte, err error) { + salts, err := randomBytes(16) + if err != nil { + return nil, nil, err + } + validation, keySalt := salts[:8], salts[8:] + entry = append(append(hash2B(password, validation, udata, 6), validation...), keySalt...) + // hash2B always returns 32 bytes, so the cipher cannot refuse the key. + block, _ := aes.NewCipher(hash2B(password, keySalt, udata, 6)) + wrapped = make([]byte, 32) + cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(wrapped, key) + return entry, wrapped, nil +} + +// permsEntry is the block that lets a reader check the permissions have not +// been tampered with: they are encrypted with the file key itself. +func permsEntry(key []byte, perm uint32) ([]byte, error) { + tail, err := randomBytes(4) + if err != nil { + return nil, err + } + plain := []byte{ + byte(perm), byte(perm >> 8), byte(perm >> 16), byte(perm >> 24), + 0xFF, 0xFF, 0xFF, 0xFF, + 'T', // metadata is encrypted too + 'a', 'd', 'b', // the specification's own marker + tail[0], tail[1], tail[2], tail[3], + } + // The file key is 32 bytes by construction. + block, _ := aes.NewCipher(key) + out := make([]byte, 16) + // One block, with no chaining: this is the one place the format uses it. + block.Encrypt(out, plain) + return out, nil +} + +// newLegacyEncrypter builds the revision 4 form, whose key comes from the +// passwords rather than the other way round. +func newLegacyEncrypter(e Encryption, perm uint32, id []byte) (*encrypter, error) { + const n = 16 // 128 bits + owner := legacyOwnerValue([]byte(e.OwnerPassword), []byte(e.UserPassword), n) + key := legacyFileKey(padPassword([]byte(e.UserPassword)), owner, id, int32(perm), n, 4, true) + user := legacyUserValue(key, id) + return &encrypter{ + key: key, + method: cryptAESV2, + id: id, + dict: Dict{ + "Filter": Name("Standard"), + "V": Integer(4), + "R": Integer(4), + "Length": Integer(128), + "P": Integer(int32(perm)), + "O": String(owner), + "U": String(user), + "CF": Dict{"StdCF": Dict{"CFM": Name("AESV2"), "Length": Integer(16)}}, + "StmF": Name("StdCF"), + "StrF": Name("StdCF"), + "EncryptMetadata": Bool(true), + }, + }, nil +} + +// legacyOwnerValue is algorithm 3: the /O entry, which holds the user password +// encrypted under the owner's. +func legacyOwnerValue(ownerPassword, userPassword []byte, n int) []byte { + if len(ownerPassword) == 0 { + ownerPassword = userPassword + } + sum := md5Sum(padPassword(ownerPassword)) + key := sum + for i := 0; i < 50; i++ { + key = md5Sum(key) + } + key = key[:n] + x := padPassword(userPassword) + for i := 0; i <= 19; i++ { + x = rc4Bytes(xorKey(key, i), x) + } + return x +} + +// legacyUserValue is algorithm 5: the /U entry. +func legacyUserValue(key, id []byte) []byte { + x := rc4Bytes(key, md5Sum(append(append([]byte{}, pad...), id...))) + for i := 1; i <= 19; i++ { + x = rc4Bytes(xorKey(key, i), x) + } + return append(x, make([]byte, 16)...) +} + +// randomSource is where randomness comes from. It is a variable so a test +// can take it away and see that a file is refused rather than written +// without the protection it was asked for. +var randomSource = rand.Read + +// randomBytes is the only source of randomness here, and the reason an +// encrypted file is not reproducible. +func randomBytes(n int) ([]byte, error) { + out := make([]byte, n) + if _, err := randomSource(out); err != nil { + return nil, fmt.Errorf("reader: no randomness available: %w", err) + } + return out, nil +} + +// apply encrypts one object's strings and, for a stream, its data. +func (e *encrypter) apply(num, gen int, o Object) Object { + switch v := o.(type) { + case String: + return String(e.encryptBytes(num, gen, v)) + case Array: + out := make(Array, len(v)) + for i, x := range v { + out[i] = e.apply(num, gen, x) + } + return out + case Dict: + out := Dict{} + for k, x := range v { + out[k] = e.apply(num, gen, x) + } + return out + case *Stream: + out := &Stream{Dict: Dict{}, Raw: e.encryptBytes(num, gen, v.Raw)} + for k, x := range v.Dict { + out.Dict[k] = e.apply(num, gen, x) + } + return out + } + return o +} + +// encryptBytes protects one object's bytes. +func (e *encrypter) encryptBytes(num, gen int, data []byte) []byte { + dec := &decryptor{key: e.key} + key := dec.objectKey(num, gen, e.method) + iv, err := randomBytes(aes.BlockSize) + if err != nil { + // Writing the bytes as they are would hand out in the clear what was + // asked to be protected, so the failure is remembered and the whole + // file is refused. + e.note(err) + return data + } + // The file key is 32 bytes by construction. + block, _ := aes.NewCipher(key) + // The padding CBC calls for, always at least one whole block of it. + n := aes.BlockSize - len(data)%aes.BlockSize + padded := append(append([]byte{}, data...), bytes.Repeat([]byte{byte(n)}, n)...) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + return append(iv, out...) +} + +// md5Sum is the hash the pre-2.0 handlers are built on. +func md5Sum(b []byte) []byte { + sum := md5.Sum(b) + return sum[:] +} + +// writeEncryptDict writes the /Encrypt dictionary and returns the trailer +// entries a reader needs to find and use it. The dictionary is the one object +// in the file that is never encrypted, since it says how to decrypt the rest. +func (w *Writer) writeEncryptDict() Dict { + if w.encrypt == nil { + return nil + } + ref := w.Reserve() + w.encrypt.number = ref.Num + w.writeInlineRaw(ref, w.encrypt.dict) + // The identifier takes part in the older key derivation and has to be in + // the trailer for a reader to reach it. + id := String(w.encrypt.id) + return Dict{"Encrypt": ref, "ID": Array{id, id}} +} + +// note keeps the first thing that went wrong while encrypting, so a file that +// could not be protected is refused rather than written in the clear. +func (e *encrypter) note(err error) { + if e.err == nil { + e.err = err + } +} diff --git a/vendor/github.com/go-pdfkit/reader/filter.go b/vendor/github.com/go-pdfkit/reader/filter.go new file mode 100644 index 0000000..c26248b --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/filter.go @@ -0,0 +1,475 @@ +package reader + +import ( + "bytes" + "compress/flate" + "compress/zlib" + "fmt" + "io" +) + +// maxDecodedSize caps what one stream may expand to. A few hundred bytes of +// Flate can name gigabytes, and a reader that runs in a browser tab must +// refuse rather than fill the heap. Tests lower it. +var maxDecodedSize int64 = 1 << 30 + +// ImageFilter reports whether a filter yields an encoded image rather than a +// byte stream. [Decode] stops at one of these and hands the caller the still +// encoded bytes, because decoding them is an image decoder's job. +// +// /CCITTFaxDecode was on this list and is not any more. It does not carry an +// image with its own idea of how many components it has and how deep they are, +// the way DCT and JPX do: it produces bilevel samples, one bit a pixel, and the +// stream dictionary says what they mean. That is a byte stream, so it is a +// filter — and decoding it here means every caller gets it rather than each +// writing its own. See ccitt.go. +func ImageFilter(n Name) bool { + switch n { + case "DCTDecode", "DCT", "JPXDecode", "JBIG2Decode": + return true + } + return false +} + +// A Decoded is the outcome of applying a stream's filter chain, including the +// outcome of a chain that could not be finished. +// +// The point of the type is that a caller can tell the three outcomes apart +// without having to be careful. Bytes a filter decoded and bytes no filter +// decoded arrive in different fields, so the second kind cannot be painted as +// samples or tokenised as content by a caller that forgot to check a flag. +type Decoded struct { + // Data is what the chain decoded. A recovered decode leaves fewer bytes + // here than the stream meant to carry, but they are bytes of the kind it + // meant to carry: Data never holds bytes that no filter has decoded. + // + // The one case where Data is still encoded is the deliberate one: a chain + // that stopped at an image filter, which Image names and Recovered does + // not flag, because stopping there is the contract. + Data []byte + + // Undecoded holds the bytes the chain could not get past, still in the + // encoding Filter names. It is set instead of Data — never beside it — + // when the filter that failed produced nothing at all. + // + // This is where a compressed content stream, a truncated fax, or a filter + // nobody implements ends up. A caller that wants the stream as it lies has + // to ask for it by a name that says what it is; a caller that reads Data + // cannot be handed it by accident. Painting undecoded bytes as a one-bit + // image looks like a page with something on it, which is why nothing about + // this is left to a flag. + Undecoded []byte + + // Image names the image filter the chain stopped at, when it stopped at + // one; Data is then the bytes still encoded in it. + Image Name + + // Recovered says the chain could not be run to the end. A caller that must + // not act on damaged data stops here — or calls [Decode], which refuses + // outright. + Recovered bool + + // Cause says why the chain stopped, and is set exactly when Recovered is. + Cause error + + // Filter names the filter that could not be applied, when one is to blame: + // a chain whose /Filter entry itself is unreadable blames nothing. + Filter Name +} + +// DecodeRecovering applies a stream dictionary's filter chain to raw and never +// fails. A filter that cannot be applied — corrupt Flate data, a stream that +// stops in the middle, a filter name nobody implements — ends the chain, and +// what the filters before it produced is returned with [Decoded.Recovered] +// set. That is deliberately what every other reader does: refusing the stream +// loses a page the file can still show. +// +// The salvage is always as far down the chain as the filters got, never the +// bytes as they arrived: a damaged Flate stream yields the prefix it did +// inflate, in [Decoded.Data]. A filter that produced nothing at all leaves the +// bytes in [Decoded.Undecoded] instead, still in its encoding, because that is +// what they are. +func DecodeRecovering(d Dict, raw []byte, resolve Resolver) Decoded { + filters, parms, err := filterChain(d, resolve) + if err != nil { + return Decoded{Undecoded: raw, Recovered: true, Cause: err} + } + data := raw + for i, f := range filters { + if ImageFilter(f) { + return Decoded{Data: data, Image: f} + } + out, err := applyFilter(f, data, parms[i], resolve) + if err != nil { + if len(out) == 0 { + return Decoded{Undecoded: data, Recovered: true, Cause: err, Filter: f} + } + return Decoded{Data: out, Recovered: true, Cause: err, Filter: f} + } + data = out + } + return Decoded{Data: data} +} + +// Decode applies a stream dictionary's filter chain to raw. It returns the +// decoded bytes and, when the chain ends in an image filter, that filter's +// name together with the bytes still encoded in it. +// +// Decode is the strict reading: a chain that cannot be run to the end is an +// error and yields no bytes. [DecodeRecovering] is the lenient one, and says +// which of the two it gave you. +func Decode(d Dict, raw []byte, resolve Resolver) ([]byte, Name, error) { + r := DecodeRecovering(d, raw, resolve) + if r.Recovered { + return nil, "", r.Cause + } + return r.Data, r.Image, nil +} + +// DecodeStream is Decode for a parsed stream. +func DecodeStream(s *Stream, resolve Resolver) ([]byte, Name, error) { + return Decode(s.Dict, s.Raw, resolve) +} + +// DecodeStreamRecovering is DecodeRecovering for a parsed stream. +func DecodeStreamRecovering(s *Stream, resolve Resolver) Decoded { + return DecodeRecovering(s.Dict, s.Raw, resolve) +} + +// filterChain reads /Filter and /DecodeParms, each of which may be a single +// value or an array, and returns them aligned. +func filterChain(d Dict, r Resolver) ([]Name, []Dict, error) { + fo, err := Resolve(d.Get("Filter"), r) + if err != nil { + return nil, nil, err + } + var names []Name + switch v := fo.(type) { + case Null: + case Name: + names = []Name{v} + case Array: + for _, e := range v { + eo, err := Resolve(e, r) + if err != nil { + return nil, nil, err + } + n, ok := ToName(eo) + if !ok { + return nil, nil, fmt.Errorf("reader: /Filter array holds a %s, not a name", eo.Kind()) + } + names = append(names, n) + } + default: + return nil, nil, fmt.Errorf("reader: /Filter is a %s, not a name or an array", fo.Kind()) + } + + parms := make([]Dict, len(names)) + po, err := Resolve(d.Get("DecodeParms"), r) + if err != nil { + return nil, nil, err + } + switch v := po.(type) { + case Null: + case Dict: + if len(parms) > 0 { + parms[0] = v + } + case Array: + for i, e := range v { + if i >= len(parms) { + break + } + eo, err := Resolve(e, r) + if err != nil { + return nil, nil, err + } + if pd, ok := ToDict(eo); ok { + parms[i] = pd + } + } + default: + return nil, nil, fmt.Errorf("reader: /DecodeParms is a %s, not a dictionary or an array", po.Kind()) + } + return names, parms, nil +} + +// applyFilter runs one filter. The abbreviated names are the ones inline +// images use; regular streams may legally carry them too. +func applyFilter(f Name, data []byte, parm Dict, r Resolver) ([]byte, error) { + switch f { + case "FlateDecode", "Fl": + out, err := flateDecode(data) + if err != nil { + return salvage(out, err, parm, r) + } + return applyPredictor(out, parm, r) + case "LZWDecode", "LZW": + early := intParm(parm, "EarlyChange", 1, r) + out, err := lzwDecode(data, early != 0) + if err != nil { + return salvage(out, err, parm, r) + } + return applyPredictor(out, parm, r) + case "ASCIIHexDecode", "AHx": + return asciiHexDecode(data) + case "ASCII85Decode", "A85": + return ascii85Decode(data) + case "RunLengthDecode", "RL": + return runLengthDecode(data) + case "CCITTFaxDecode", "CCF": + return ccittDecode(data, ccittParamsOf(parm, r)) + case "Crypt": + return cryptFilter(data, parm, r) + } + return nil, fmt.Errorf("reader: unsupported filter /%s", f) +} + +// cryptFilter applies the /Crypt filter, which does not transform anything: it +// names the crypt filter a stream's bytes were encrypted with, and /Identity — +// which is also what /Crypt with no /Name means — says they were not encrypted +// at all. Either way the document that owns the stream has already dealt with +// its encryption by the time the filter chain runs, so there is nothing here +// left to undo. +// +// A /Name this reader cannot account for is reported rather than waved through, +// because the bytes would then be ciphertext that only looks like data. +func cryptFilter(data []byte, parm Dict, r Resolver) ([]byte, error) { + o, err := Resolve(parm.Get("Name"), r) + if err != nil { + return nil, fmt.Errorf("reader: /Crypt filter: %w", err) + } + if n, ok := ToName(o); ok && n != "Identity" { + // No bytes come back: they are ciphertext, and handing them over as + // though the filter had run is how ciphertext gets painted. + return nil, fmt.Errorf("reader: /Crypt filter names /%s, which this reader cannot apply", n) + } + return data, nil +} + +// firstFilter names the first filter of a stream's chain. Only direct values +// are read: it is consulted while the file key is being established, before +// following an indirect reference is safe. +func firstFilter(d Dict) (Name, bool) { + switch v := d.Get("Filter").(type) { + case Name: + return v, true + case Array: + if len(v) > 0 { + n, ok := ToName(v[0]) + return n, ok + } + } + return "", false +} + +// firstDecodeParms is the parameter dictionary belonging to the first filter, +// read directly for the same reason. +func firstDecodeParms(d Dict) Dict { + switch v := d.Get("DecodeParms").(type) { + case Dict: + return v + case Array: + if len(v) > 0 { + if pd, ok := ToDict(v[0]); ok { + return pd + } + } + } + return nil +} + +// salvage finishes a filter that stopped part-way. The prefix it did produce is +// still worth having, and the predictor still applies to it: the predictors a +// PDF may name all undo one row at a time, so a buffer that stops in the middle +// undoes up to where it stops. +func salvage(out []byte, err error, parm Dict, r Resolver) ([]byte, error) { + if len(out) == 0 { + return nil, err + } + if p, perr := applyPredictor(out, parm, r); perr == nil { + return p, err + } + return out, err +} + +// intParm reads an integer decode parameter, falling back to its default. +func intParm(parm Dict, key Name, def int, r Resolver) int { + if parm == nil { + return def + } + o, err := Resolve(parm.Get(key), r) + if err != nil { + return def + } + n, ok := ToInt(o) + if !ok { + return def + } + return int(n) +} + +// flateDecode inflates a stream. Producers emit both zlib-wrapped and bare +// deflate data, sometimes with leading white-space, and truncate the last +// stream in a damaged file. All four cases yield whatever bytes are there — but +// a prefix comes back with the error that ended it, never dressed up as a whole +// stream, so the caller can tell the difference. +func flateDecode(data []byte) ([]byte, error) { + i := 0 + for i < len(data) && isSpace(data[i]) { + i++ + } + data = data[i:] + if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil { + out, err := readAllCapped(zr) + if err == nil { + return out, nil + } + if len(out) > 0 { + return out, fmt.Errorf("reader: FlateDecode: %w", err) + } + } + out, err := readAllCapped(flate.NewReader(bytes.NewReader(data))) + if err != nil { + return out, fmt.Errorf("reader: FlateDecode: %w", err) + } + return out, nil +} + +// readAllCapped reads r, refusing to grow past maxDecodedSize. +func readAllCapped(r io.Reader) ([]byte, error) { + var buf bytes.Buffer + n, err := io.Copy(&buf, io.LimitReader(r, maxDecodedSize+1)) + if err != nil { + return buf.Bytes(), err + } + if n > maxDecodedSize { + return nil, fmt.Errorf("reader: decoded stream exceeds %d bytes", maxDecodedSize) + } + return buf.Bytes(), nil +} + +// asciiHexDecode reads hexadecimal digits up to the '>' terminator, padding a +// final odd digit with zero. +func asciiHexDecode(data []byte) ([]byte, error) { + out := make([]byte, 0, len(data)/2) + hi := -1 + for i := 0; i < len(data); i++ { + c := data[i] + if c == '>' { + break + } + if isSpace(c) { + continue + } + v := hexVal(c) + if v < 0 { + return out, fmt.Errorf("reader: ASCIIHexDecode: invalid digit %q", rune(c)) + } + if hi < 0 { + hi = v + continue + } + out = append(out, byte(hi<<4|v)) + hi = -1 + } + if hi >= 0 { + out = append(out, byte(hi<<4)) + } + return out, nil +} + +// ascii85Decode reads base-85 groups up to the "~>" terminator. +func ascii85Decode(data []byte) ([]byte, error) { + if bytes.HasPrefix(data, []byte("<~")) { + data = data[2:] + } + out := make([]byte, 0, len(data)*4/5) + var group [5]byte + n := 0 + flush := func(n int) { + for i := n; i < 5; i++ { + group[i] = 'u' + } + v := uint32(0) + for _, c := range group { + v = v*85 + uint32(c-'!') + } + var b [4]byte + b[0], b[1], b[2], b[3] = byte(v>>24), byte(v>>16), byte(v>>8), byte(v) + out = append(out, b[:n-1]...) + } + for i := 0; i < len(data); i++ { + c := data[i] + if isSpace(c) { + continue + } + if c == '~' { + break + } + if c == 'z' && n == 0 { + out = append(out, 0, 0, 0, 0) + continue + } + if c < '!' || c > 'u' { + return out, fmt.Errorf("reader: ASCII85Decode: invalid character %q", rune(c)) + } + group[n] = c + n++ + if n == 5 { + if err := checkA85(group); err != nil { + return out, err + } + flush(5) + n = 0 + } + } + if n == 1 { + return out, fmt.Errorf("reader: ASCII85Decode: truncated final group") + } + if n > 1 { + flush(n) + } + return out, nil +} + +// checkA85 rejects a full group that names more than 2^32-1. +func checkA85(g [5]byte) error { + v := uint64(0) + for _, c := range g { + v = v*85 + uint64(c-'!') + } + if v > 0xFFFFFFFF { + return fmt.Errorf("reader: ASCII85Decode: group overflows 32 bits") + } + return nil +} + +// runLengthDecode expands the byte-oriented run-length encoding: a length byte +// below 128 introduces that many literal bytes plus one, above 128 repeats the +// next byte, and 128 ends the data. +func runLengthDecode(data []byte) ([]byte, error) { + out := []byte{} + for i := 0; i < len(data); { + n := int(data[i]) + i++ + switch { + case n == 128: + return out, nil + case n < 128: + end := i + n + 1 + if end > len(data) { + return out, fmt.Errorf("reader: RunLengthDecode: truncated literal run") + } + out = append(out, data[i:end]...) + i = end + default: + if i >= len(data) { + return out, fmt.Errorf("reader: RunLengthDecode: truncated repeat run") + } + out = append(out, bytes.Repeat(data[i:i+1], 257-n)...) + i++ + } + } + return out, nil +} diff --git a/vendor/github.com/go-pdfkit/reader/inline.go b/vendor/github.com/go-pdfkit/reader/inline.go new file mode 100644 index 0000000..aeac9f0 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/inline.go @@ -0,0 +1,155 @@ +package reader + +import "bytes" + +// inlineImageEnd finds where an inline image's data stops — the one genuinely +// ambiguous thing in a content stream, since the data is raw bytes that may +// spell EI themselves. +// +// The answers are tried in order of how much they can be trusted: an image +// with no filter says exactly how long it is through its width, height, depth +// and colour space; a declared /L is believed when an EI really does follow +// it; and failing both, every EI in the data is tried in turn and the first +// one whose data actually decodes is taken. That last step is what a scan +// alone gets wrong, because compressed bytes do spell EI by accident. +func inlineImageEnd(b []byte, start int, d Dict) (int, error) { + if n, ok := inlineImageDataLength(d); ok && start+n <= len(b) && eiFollows(b, start+n) { + return start + n, nil + } + if n, ok := ToInt(d.Get("L")); ok && n >= 0 && start+int(n) <= len(b) && eiFollows(b, start+int(n)) { + return start + int(n), nil + } + expanded := (&InlineImage{Dict: d}).Expanded() + want, haveWant := inlineSampleBytes(d) + // White-space before EI is how the keyword is meant to be written; a + // second pass allows for producers that run the data straight into it. + for _, needSpace := range []bool{true, false} { + for from := start; ; { + end, resume := nextEI(b, start, from, needSpace) + if end < 0 { + break + } + if inlineDataDecodes(expanded, b[start:end], want, haveWant) { + return end, nil + } + from = resume + } + } + return 0, &SyntaxError{start, "an inline image has no EI"} +} + +// nextEI returns where the image data would end for the next EI candidate at +// or after from, and where to resume searching. It reports -1 when there is no +// candidate left. +func nextEI(b []byte, start, from int, needSpace bool) (end, resume int) { + for i := from; i+1 < len(b); i++ { + if b[i] != 'E' || b[i+1] != 'I' { + continue + } + if i+2 < len(b) && isRegular(b[i+2]) { + continue + } + if i > start && isSpace(b[i-1]) { + return i - 1, i + 2 + } + if !needSpace { + return i, i + 2 + } + } + return -1, len(b) +} + +// eiFollows reports whether an EI keyword stands at i, after white-space. +func eiFollows(b []byte, i int) bool { + for i < len(b) && isSpace(b[i]) { + i++ + } + if !bytes.HasPrefix(b[i:], []byte("EI")) { + return false + } + return i+2 >= len(b) || !isRegular(b[i+2]) +} + +// inlineDataDecodes reports whether a candidate stretch of bytes is really the +// whole of the image: it has to run through the declared filters without +// complaint, produce the number of samples the image says it has, and — for a +// JPEG, which no filter here can check — begin and end where a JPEG does. +func inlineDataDecodes(expanded Dict, data []byte, want int, haveWant bool) bool { + if expanded.Get("Filter").Kind() == KindNull { + return true + } + out, img, err := Decode(expanded, data, nil) + if err != nil { + return false + } + if img == "DCTDecode" { + return len(out) > 4 && out[0] == 0xFF && out[1] == 0xD8 && + out[len(out)-2] == 0xFF && out[len(out)-1] == 0xD9 + } + if img != "" { + return true + } + return !haveWant || len(out) == want +} + +// inlineComponents reports how many samples one pixel of an inline image has, +// for the colour spaces that can be named inline. +func inlineComponents(d Dict) (int, bool) { + if b, ok := ToBool(d.Get("IM")); ok && b { + return 1, true + } + cs, ok := ToName(d.Get("CS")) + if !ok { + if _, isArray := ToArray(d.Get("CS")); isArray { + // An inline [/Indexed …] space has one component per sample. + return 1, true + } + return 0, false + } + switch cs { + case "G", "DeviceGray", "CalGray", "I", "Indexed": + return 1, true + case "RGB", "DeviceRGB", "CalRGB": + return 3, true + case "CMYK", "DeviceCMYK": + return 4, true + } + // A name that refers to a colour space in the page's resources: not + // something the arithmetic can settle here. + return 0, false +} + +// inlineSampleBytes computes how many bytes an inline image's samples occupy +// once decoded. +func inlineSampleBytes(d Dict) (int, bool) { + w, okW := ToInt(d.Get("W")) + h, okH := ToInt(d.Get("H")) + if !okW || !okH || w <= 0 || h <= 0 { + return 0, false + } + bpc := int64(8) + if b, ok := ToInt(d.Get("BPC")); ok { + bpc = b + } + if im, ok := ToBool(d.Get("IM")); ok && im { + bpc = 1 + } + if bpc <= 0 || bpc > 16 { + return 0, false + } + comps, ok := inlineComponents(d) + if !ok { + return 0, false + } + row := (w*int64(comps)*bpc + 7) / 8 + return int(row * h), true +} + +// inlineImageDataLength is inlineSampleBytes for an image with no filter, for +// which the samples are the data. +func inlineImageDataLength(d Dict) (int, bool) { + if d.Get("F").Kind() != KindNull || d.Get("Filter").Kind() != KindNull { + return 0, false + } + return inlineSampleBytes(d) +} diff --git a/vendor/github.com/go-pdfkit/reader/lex.go b/vendor/github.com/go-pdfkit/reader/lex.go new file mode 100644 index 0000000..63dbc7d --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/lex.go @@ -0,0 +1,340 @@ +package reader + +import ( + "fmt" + "strconv" +) + +// A SyntaxError reports malformed PDF syntax and where it was found, so a +// repair pass can restart from a known offset instead of giving up. +type SyntaxError struct { + Offset int + Msg string +} + +// Error implements the error interface. +func (e *SyntaxError) Error() string { + return fmt.Sprintf("reader: %s at offset %d", e.Msg, e.Offset) +} + +// isSpace reports the six bytes the specification calls white-space, +// NUL included. +func isSpace(c byte) bool { + return c == 0 || c == 9 || c == 10 || c == 12 || c == 13 || c == 32 +} + +// isDelim reports the eight delimiter bytes. +func isDelim(c byte) bool { + switch c { + case '(', ')', '<', '>', '[', ']', '{', '}', '/', '%': + return true + } + return false +} + +// isRegular reports a byte that may appear inside a name, a number or a +// keyword: anything that is neither white-space nor a delimiter. +func isRegular(c byte) bool { return !isSpace(c) && !isDelim(c) } + +// hexVal decodes one hexadecimal digit, or reports -1. +func hexVal(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 +} + +type tokKind uint8 + +const ( + tokEOF tokKind = iota + tokInteger + tokReal + tokString + tokName + tokArrayOpen + tokArrayClose + tokDictOpen + tokDictClose + tokBraceOpen + tokBraceClose + tokKeyword +) + +// A token is one lexical unit. text carries the bytes of a string, name or +// keyword; i and f carry the value of a number. +type token struct { + kind tokKind + text []byte + i int64 + f float64 + pos int +} + +// A lexer walks the PDF object syntax over a byte slice. It never copies the +// input except where an escape sequence forces it. +type lexer struct { + buf []byte + pos int +} + +// skipSpace consumes white-space and comments. +func (l *lexer) skipSpace() { + for l.pos < len(l.buf) { + c := l.buf[l.pos] + if isSpace(c) { + l.pos++ + continue + } + if c == '%' { + for l.pos < len(l.buf) && l.buf[l.pos] != '\n' && l.buf[l.pos] != '\r' { + l.pos++ + } + continue + } + return + } +} + +// next returns the token at the current position and advances past it. +func (l *lexer) next() (token, error) { + l.skipSpace() + start := l.pos + if l.pos >= len(l.buf) { + return token{kind: tokEOF, pos: start}, nil + } + switch c := l.buf[l.pos]; { + case c == '[': + l.pos++ + return token{kind: tokArrayOpen, pos: start}, nil + case c == ']': + l.pos++ + return token{kind: tokArrayClose, pos: start}, nil + case c == '{': + l.pos++ + return token{kind: tokBraceOpen, pos: start}, nil + case c == '}': + l.pos++ + return token{kind: tokBraceClose, pos: start}, nil + case c == '<': + if l.pos+1 < len(l.buf) && l.buf[l.pos+1] == '<' { + l.pos += 2 + return token{kind: tokDictOpen, pos: start}, nil + } + return l.hexString(start) + case c == '>': + if l.pos+1 < len(l.buf) && l.buf[l.pos+1] == '>' { + l.pos += 2 + return token{kind: tokDictClose, pos: start}, nil + } + l.pos++ + return token{}, &SyntaxError{start, "stray '>'"} + case c == '(': + return l.literalString(start) + case c == ')': + l.pos++ + return token{}, &SyntaxError{start, "stray ')'"} + case c == '/': + return l.name(start) + case c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'): + return l.number(start) + default: + return l.keyword(start), nil + } +} + +// keyword reads a run of regular characters: an operator, obj, endobj, R, +// true, false, null. +func (l *lexer) keyword(start int) token { + p := l.pos + for p < len(l.buf) && isRegular(l.buf[p]) { + p++ + } + l.pos = p + return token{kind: tokKeyword, text: l.buf[start:p], pos: start} +} + +// number reads an integer or a real. It stops at the first byte that cannot +// continue the number rather than swallowing the whole run of regular +// characters: producers do write "3.4-5" for two numbers, and a reader that +// rejects that loses the rest of the content stream. +func (l *lexer) number(start int) (token, error) { + p := l.pos + for p < len(l.buf) && (l.buf[p] == '+' || l.buf[p] == '-') { + p++ + } + digits, dot := 0, false + for p < len(l.buf) { + c := l.buf[p] + switch { + case c >= '0' && c <= '9': + digits++ + case c == '.' && !dot: + dot = true + default: + goto done + } + p++ + } +done: + s := l.buf[l.pos:p] + l.pos = p + if digits == 0 { + return token{}, &SyntaxError{start, "malformed number " + strconv.Quote(string(s))} + } + if i, err := strconv.ParseInt(string(s), 10, 64); err == nil { + return token{kind: tokInteger, i: i, f: float64(i), pos: start}, nil + } + f, err := parseReal(s) + if err != nil { + return token{}, &SyntaxError{start, "malformed number " + strconv.Quote(string(s))} + } + return token{kind: tokReal, i: int64(f), f: f, pos: start}, nil +} + +// parseReal accepts the doubled sign of "--5", which producers do write; only +// the first sign counts. Everything else has already been filtered out by the +// scan above. +func parseReal(s []byte) (float64, error) { + clean := make([]byte, 0, len(s)) + i := 0 + if i < len(s) && (s[i] == '+' || s[i] == '-') { + clean = append(clean, s[i]) + i++ + } + for i < len(s) && (s[i] == '+' || s[i] == '-') { + i++ + } + clean = append(clean, s[i:]...) + return strconv.ParseFloat(string(clean), 64) +} + +// literalString reads a (parenthesised) string, resolving escapes. Nested +// parentheses nest; an end-of-line inside the string, however written, becomes +// a single line feed. +func (l *lexer) literalString(start int) (token, error) { + l.pos++ + depth := 1 + out := []byte{} + for l.pos < len(l.buf) { + c := l.buf[l.pos] + l.pos++ + switch c { + case '\\': + if l.pos >= len(l.buf) { + return token{}, &SyntaxError{start, "unterminated string"} + } + e := l.buf[l.pos] + l.pos++ + switch { + case e == 'n': + out = append(out, '\n') + case e == 'r': + out = append(out, '\r') + case e == 't': + out = append(out, '\t') + case e == 'b': + out = append(out, '\b') + case e == 'f': + out = append(out, '\f') + case e == '\r': + // A backslash before an end-of-line continues the line. + if l.pos < len(l.buf) && l.buf[l.pos] == '\n' { + l.pos++ + } + case e == '\n': + case e >= '0' && e <= '7': + v := int(e - '0') + for k := 0; k < 2 && l.pos < len(l.buf) && l.buf[l.pos] >= '0' && l.buf[l.pos] <= '7'; k++ { + v = v*8 + int(l.buf[l.pos]-'0') + l.pos++ + } + out = append(out, byte(v)) + default: + // \( \) \\ and, per the specification, any other escaped + // character stands for itself. + out = append(out, e) + } + case '(': + depth++ + out = append(out, c) + case ')': + depth-- + if depth == 0 { + return token{kind: tokString, text: out, pos: start}, nil + } + out = append(out, c) + case '\r': + if l.pos < len(l.buf) && l.buf[l.pos] == '\n' { + l.pos++ + } + out = append(out, '\n') + default: + out = append(out, c) + } + } + return token{}, &SyntaxError{start, "unterminated string"} +} + +// hexString reads a <hex> string. White-space is ignored and a final odd digit +// is padded with zero, as the specification requires. +func (l *lexer) hexString(start int) (token, error) { + l.pos++ + out := []byte{} + hi := -1 + for l.pos < len(l.buf) { + c := l.buf[l.pos] + l.pos++ + if c == '>' { + if hi >= 0 { + out = append(out, byte(hi<<4)) + } + return token{kind: tokString, text: out, pos: start}, nil + } + if isSpace(c) { + continue + } + v := hexVal(c) + if v < 0 { + // Producers do emit a stray byte inside <…>; skipping it is what + // every other reader does, and losing the whole object is worse. + continue + } + if hi < 0 { + hi = v + continue + } + out = append(out, byte(hi<<4|v)) + hi = -1 + } + return token{}, &SyntaxError{start, "unterminated hex string"} +} + +// name reads a /Name, resolving #xx escapes. +func (l *lexer) name(start int) (token, error) { + l.pos++ + out := []byte{} + for l.pos < len(l.buf) && isRegular(l.buf[l.pos]) { + c := l.buf[l.pos] + l.pos++ + if c != '#' { + out = append(out, c) + continue + } + if l.pos+1 >= len(l.buf) { + return token{}, &SyntaxError{start, "truncated #xx escape in name"} + } + h1, h2 := hexVal(l.buf[l.pos]), hexVal(l.buf[l.pos+1]) + if h1 < 0 || h2 < 0 { + return token{}, &SyntaxError{start, "invalid #xx escape in name"} + } + out = append(out, byte(h1<<4|h2)) + l.pos += 2 + } + return token{kind: tokName, text: out, pos: start}, nil +} diff --git a/vendor/github.com/go-pdfkit/reader/lzw.go b/vendor/github.com/go-pdfkit/reader/lzw.go new file mode 100644 index 0000000..9eb58c9 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/lzw.go @@ -0,0 +1,86 @@ +package reader + +import "fmt" + +// LZW code points that are not table entries. +const ( + lzwClear = 256 + lzwEOD = 257 + lzwFirst = 258 + lzwMax = 4096 +) + +// lzwDecode expands PDF's variable-code-width LZW. +// +// The standard library's compress/lzw cannot be used here: PDF's /EarlyChange +// parameter defaults to 1, which widens the code one entry before the table +// actually fills, and compress/lzw implements neither that convention nor a +// way to ask for it. Getting this wrong does not fail — it silently produces +// plausible rubbish a few hundred bytes in. +func lzwDecode(data []byte, early bool) ([]byte, error) { + var table [lzwMax][]byte + for i := 0; i < 256; i++ { + table[i] = []byte{byte(i)} + } + next := lzwFirst + width := 9 + bit := 0 + var out, prev []byte + + for { + if bit+width > len(data)*8 { + // A truncated stream keeps what it managed to say. + return out, nil + } + code := 0 + for k := 0; k < width; k++ { + code = code<<1 | int(data[(bit+k)/8]>>(7-(bit+k)%8)&1) + + } + bit += width + + switch code { + case lzwEOD: + return out, nil + case lzwClear: + next, width, prev = lzwFirst, 9, nil + continue + } + + var entry []byte + switch { + case code < next && table[code] != nil: + entry = table[code] + case code == next && prev != nil: + // The encoder may name the entry it is about to define. + entry = append(append([]byte{}, prev...), prev[0]) + default: + // What the stream did say is kept, with the reason it stopped. + return out, fmt.Errorf("reader: LZWDecode: code %d is not in the table", code) + } + out = append(out, entry...) + + if prev != nil && next < lzwMax { + table[next] = append(append([]byte{}, prev...), entry[0]) + next++ + } + prev = entry + + // With EarlyChange the width grows one entry sooner. Both conventions + // are the same comparison against a shifted count. + count := next + if early { + count++ + } + switch { + case count >= 2048: + width = 12 + case count >= 1024: + width = 11 + case count >= 512: + width = 10 + default: + width = 9 + } + } +} diff --git a/vendor/github.com/go-pdfkit/reader/object.go b/vendor/github.com/go-pdfkit/reader/object.go new file mode 100644 index 0000000..cbb81f4 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/object.go @@ -0,0 +1,244 @@ +// Package reader takes existing PDF bytes apart: the object syntax, the +// cross-reference machinery, stream filters, and the document structure built +// on top of them. It is the parsing counterpart of github.com/go-pdfkit/pdfkit, +// which writes PDF. +// +// Nothing outside the Go standard library is used, so the package builds for +// js/wasm and for every architecture the fleet targets. +package reader + +import "fmt" + +// Kind names the eight basic PDF object types plus the two the file format +// adds on top of them: streams and indirect references. +type Kind uint8 + +// The object kinds. [Object.Kind] reports one of these. +const ( + KindNull Kind = iota + KindBool + KindInteger + KindReal + KindString + KindName + KindArray + KindDict + KindStream + KindRef +) + +var kindNames = [...]string{ + KindNull: "null", + KindBool: "boolean", + KindInteger: "integer", + KindReal: "real", + KindString: "string", + KindName: "name", + KindArray: "array", + KindDict: "dictionary", + KindStream: "stream", + KindRef: "reference", +} + +// String names the kind, for error messages. +func (k Kind) String() string { + if int(k) >= len(kindNames) { + return fmt.Sprintf("Kind(%d)", uint8(k)) + } + return kindNames[k] +} + +// An Object is any value the PDF object syntax can express. The concrete types +// are [Null], [Bool], [Integer], [Real], [String], [Name], [Array], [Dict], +// [Ref] and *[Stream]; Kind tells them apart without a type switch. +type Object interface { + Kind() Kind +} + +// Null is the PDF null object. A missing dictionary entry resolves to Null +// rather than to a nil Object, so callers never have to nil-check. +type Null struct{} + +// Kind implements [Object]. +func (Null) Kind() Kind { return KindNull } + +// Bool is a PDF boolean. +type Bool bool + +// Kind implements [Object]. +func (Bool) Kind() Kind { return KindBool } + +// Integer is a PDF integer. Numbers too large for an int64 are parsed as +// [Real], which is what every other reader does. +type Integer int64 + +// Kind implements [Object]. +func (Integer) Kind() Kind { return KindInteger } + +// Real is a PDF real number. +type Real float64 + +// Kind implements [Object]. +func (Real) Kind() Kind { return KindReal } + +// String is a PDF string, already unescaped: the bytes a literal or hex string +// denotes, with no interpretation of their text encoding. +type String []byte + +// Kind implements [Object]. +func (String) Kind() Kind { return KindString } + +// Name is a PDF name with its #xx escapes resolved and the leading slash +// dropped, so /Type is Name("Type"). +type Name string + +// Kind implements [Object]. +func (Name) Kind() Kind { return KindName } + +// Array is a PDF array. +type Array []Object + +// Kind implements [Object]. +func (Array) Kind() Kind { return KindArray } + +// Dict is a PDF dictionary. +type Dict map[Name]Object + +// Kind implements [Object]. +func (Dict) Kind() Kind { return KindDict } + +// Get returns the entry, or [Null] when it is absent. The value may still be a +// [Ref]; use [Resolve] to follow it. +func (d Dict) Get(k Name) Object { + if v, ok := d[k]; ok && v != nil { + return v + } + return Null{} +} + +// Ref is an indirect reference: the "12 0 R" that stands in for an object +// stored elsewhere in the file. +type Ref struct { + Num int + Gen int +} + +// Kind implements [Object]. +func (Ref) Kind() Kind { return KindRef } + +// String renders the reference the way it appears in a file. +func (r Ref) String() string { return fmt.Sprintf("%d %d R", r.Num, r.Gen) } + +// Stream is a PDF stream: a dictionary plus the raw, still-encoded bytes +// between the stream and endstream keywords. [Decode] applies the filter chain. +type Stream struct { + Dict Dict + Raw []byte +} + +// Kind implements [Object]. +func (*Stream) Kind() Kind { return KindStream } + +// A Resolver fetches the object an indirect reference names. A document +// supplies one; a nil Resolver makes every reference resolve to [Null], which +// is what a caller parsing an isolated fragment wants. +type Resolver func(Ref) (Object, error) + +// maxRefDepth bounds a chain of references pointing at references. A file can +// make that chain a cycle, and a cycle must not hang the reader. +const maxRefDepth = 64 + +// Resolve follows indirect references until it reaches a direct object. A nil +// object, an absent entry and an unresolvable reference all yield [Null]. +func Resolve(o Object, r Resolver) (Object, error) { + if o == nil { + return Null{}, nil + } + for depth := 0; ; depth++ { + ref, ok := o.(Ref) + if !ok { + return o, nil + } + if r == nil { + return Null{}, nil + } + if depth >= maxRefDepth { + return nil, fmt.Errorf("reader: reference chain longer than %d at %s", maxRefDepth, ref) + } + v, err := r(ref) + if err != nil { + return nil, err + } + if v == nil { + return Null{}, nil + } + o = v + } +} + +// ToBool reports the value of a boolean object. +func ToBool(o Object) (bool, bool) { + v, ok := o.(Bool) + return bool(v), ok +} + +// ToInt reports the value of an integer object, and of a real whose value is a +// whole number — files do write "3.0" where an integer belongs. +func ToInt(o Object) (int64, bool) { + switch v := o.(type) { + case Integer: + return int64(v), true + case Real: + if float64(int64(v)) == float64(v) { + return int64(v), true + } + } + return 0, false +} + +// ToFloat reports the value of any numeric object. +func ToFloat(o Object) (float64, bool) { + switch v := o.(type) { + case Integer: + return float64(v), true + case Real: + return float64(v), true + } + return 0, false +} + +// ToString reports the bytes of a string object. +func ToString(o Object) ([]byte, bool) { + v, ok := o.(String) + return []byte(v), ok +} + +// ToName reports the value of a name object. +func ToName(o Object) (Name, bool) { + v, ok := o.(Name) + return v, ok +} + +// ToArray reports the elements of an array object. +func ToArray(o Object) (Array, bool) { + v, ok := o.(Array) + return v, ok +} + +// ToDict reports the dictionary of a dictionary object, or a stream's own +// dictionary — a stream is a dictionary everywhere a dictionary is expected. +func ToDict(o Object) (Dict, bool) { + switch v := o.(type) { + case Dict: + return v, true + case *Stream: + return v.Dict, true + } + return nil, false +} + +// ToStream reports the stream object. +func ToStream(o Object) (*Stream, bool) { + v, ok := o.(*Stream) + return v, ok +} diff --git a/vendor/github.com/go-pdfkit/reader/objstm.go b/vendor/github.com/go-pdfkit/reader/objstm.go new file mode 100644 index 0000000..726d04a --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/objstm.go @@ -0,0 +1,128 @@ +package reader + +import ( + "bytes" + "compress/zlib" + "fmt" +) + +// objectsPerStream bounds how many objects go into one object stream. A +// stream has to be inflated whole to read any object in it, so packing +// everything into one would make opening a file read all of it. +const objectsPerStream = 200 + +// packedAt says which object stream an object was packed into, and where. +type packedAt struct { + stream int + index int +} + +// pendingObject is an object waiting to be packed. +type pendingObject struct { + ref Ref + obj Object +} + +// packObjects writes every waiting object into object streams. It is called +// once, from Finish. +func (w *Writer) packObjects() { + for start := 0; start < len(w.pending); start += objectsPerStream { + end := start + objectsPerStream + if end > len(w.pending) { + end = len(w.pending) + } + w.packGroup(w.pending[start:end]) + } + w.pending = nil +} + +// packGroup writes one object stream holding the given objects. +func (w *Writer) packGroup(group []pendingObject) { + var head, payload bytes.Buffer + for _, p := range group { + fmt.Fprintf(&head, "%d %d ", p.ref.Num, payload.Len()) + payload.Write(AppendObject(nil, p.obj)) + payload.WriteByte('\n') + } + body := append(head.Bytes(), payload.Bytes()...) + stream := w.Reserve() + for i, p := range group { + w.packed[p.ref.Num] = packedAt{stream: stream.Num, index: i} + } + w.writeInline(stream, &Stream{ + Dict: Dict{ + "Type": Name("ObjStm"), + "N": Integer(len(group)), + "First": Integer(head.Len()), + }, + Raw: body, + }) +} + +// flateCompress compresses a stream's data, which is what makes packing worth doing. +func flateCompress(data []byte) []byte { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + // A bytes.Buffer never refuses bytes, and Close only flushes. + zw.Write(data) + zw.Close() + return buf.Bytes() +} + +// finishWithXrefStream writes the cross-reference information as a stream +// rather than a table, which is the only form that can name an object inside +// an object stream. +func (w *Writer) finishWithXrefStream(trailer Dict) ([]byte, error) { + // Finish has already written the /Encrypt dictionary and put what a + // reader needs into the trailer. + w.packObjects() + xref := w.Reserve() + // The cross-reference stream is reserved after everything else — the + // object streams included — so it always carries the highest number. + high := xref.Num + start := w.buf.Len() + w.offsets[xref.Num] = start + + rows := make([]byte, 0, (high+1)*7) + for num := 0; num <= high; num++ { + switch { + case num == 0: + rows = append(rows, 0, 0, 0, 0, 0, 0xFF, 0xFF) + case w.offsets[num] != 0 || num == xref.Num: + rows = append(rows, xrefRow(1, int64(w.offsets[num]), 0)...) + default: + if at, ok := w.packed[num]; ok { + rows = append(rows, xrefRow(2, int64(at.stream), int64(at.index))...) + continue + } + rows = append(rows, 0, 0, 0, 0, 0, 0xFF, 0xFF) + } + } + + dict := Dict{ + "Type": Name("XRef"), + "Size": Integer(high + 1), + "W": Array{Integer(1), Integer(4), Integer(2)}, + } + for k, v := range trailer { + dict[k] = v + } + w.writeInlineRaw(xref, &Stream{Dict: dict, Raw: rows}) + fmt.Fprintf(&w.buf, "startxref\n%d\n%%%%EOF\n", start) + if w.encrypt != nil && w.encrypt.err != nil { + w.note(w.encrypt.err) + } + if w.err != nil { + return nil, w.err + } + return w.buf.Bytes(), nil +} + +// xrefRow renders one three-field row of a cross-reference stream. +func xrefRow(kind byte, f2, f3 int64) []byte { + return []byte{ + kind, + byte(f2 >> 24), byte(f2 >> 16), byte(f2 >> 8), byte(f2), + byte(f3 >> 8), byte(f3), + } +} diff --git a/vendor/github.com/go-pdfkit/reader/parse.go b/vendor/github.com/go-pdfkit/reader/parse.go new file mode 100644 index 0000000..186d80a --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/parse.go @@ -0,0 +1,295 @@ +package reader + +import ( + "bytes" + "strconv" +) + +// A parser turns the token stream into objects. resolve is consulted only for +// a stream's /Length, which files are allowed to store indirectly. +type parser struct { + lex lexer + resolve Resolver +} + +// ParseObject parses a single direct object from the start of b and reports how +// many bytes it consumed. +func ParseObject(b []byte) (Object, int, error) { + p := &parser{lex: lexer{buf: b}} + o, err := p.parseObject() + if err != nil { + return nil, 0, err + } + return o, p.lex.pos, nil +} + +// ParseIndirectObject parses "N G obj … endobj" from the start of b, returning +// the reference it defines, the object itself, and how many bytes it consumed. +// A stream's /Length may be an indirect reference; resolve supplies it, and a +// nil or unhelpful resolve falls back to scanning for the endstream keyword. +func ParseIndirectObject(b []byte, resolve Resolver) (Ref, Object, int, error) { + p := &parser{lex: lexer{buf: b}, resolve: resolve} + num, err := p.expectUint("object number") + if err != nil { + return Ref{}, nil, 0, err + } + gen, err := p.expectUint("generation number") + if err != nil { + return Ref{}, nil, 0, err + } + if err := p.expectKeyword("obj"); err != nil { + return Ref{}, nil, 0, err + } + ref := Ref{Num: num, Gen: gen} + // An indirect object may have no value at all — "7 0 obj endobj" — + // which the corpus does contain. Its value is null. + if p.accept("endobj") { + return ref, Null{}, p.lex.pos, nil + } + obj, err := p.parseObject() + if err != nil { + return ref, nil, 0, err + } + obj, err = p.maybeStream(obj) + if err != nil { + return ref, nil, 0, err + } + p.accept("endobj") + return ref, obj, p.lex.pos, nil +} + +// expectUint reads a non-negative integer token. +func (p *parser) expectUint(what string) (int, error) { + t, err := p.lex.next() + if err != nil { + return 0, err + } + if t.kind != tokInteger || t.i < 0 { + return 0, &SyntaxError{t.pos, what + " expected"} + } + return int(t.i), nil +} + +// expectKeyword requires the given keyword next. +func (p *parser) expectKeyword(kw string) error { + t, err := p.lex.next() + if err != nil { + return err + } + if t.kind != tokKeyword || string(t.text) != kw { + return &SyntaxError{t.pos, strconv.Quote(kw) + " expected"} + } + return nil +} + +// accept consumes the keyword if it is next, and reports whether it was. +func (p *parser) accept(kw string) bool { + save := p.lex.pos + t, err := p.lex.next() + if err == nil && t.kind == tokKeyword && string(t.text) == kw { + return true + } + p.lex.pos = save + return false +} + +// maybeStream turns a dictionary followed by the stream keyword into a Stream. +func (p *parser) maybeStream(obj Object) (Object, error) { + save := p.lex.pos + t, err := p.lex.next() + if err != nil || t.kind != tokKeyword || string(t.text) != "stream" { + p.lex.pos = save + return obj, nil + } + d, ok := obj.(Dict) + if !ok { + return nil, &SyntaxError{t.pos, "stream keyword after a " + obj.Kind().String()} + } + return p.readStream(d, t.pos) +} + +// readStream extracts the bytes between stream and endstream. The declared +// /Length is trusted only when endstream really does follow it — producers get +// it wrong often enough that the scan below is the common path, not the +// exception. +func (p *parser) readStream(d Dict, kwPos int) (Object, error) { + b := p.lex.buf + i := p.lex.pos + for i < len(b) && (b[i] == ' ' || b[i] == '\t') { + i++ + } + if i < len(b) && b[i] == '\r' { + i++ + } + if i < len(b) && b[i] == '\n' { + i++ + } + start := i + if n, ok := p.streamLength(d); ok && start+n <= len(b) { + if end := endstreamAt(b, start+n); end >= 0 { + p.lex.pos = end + return &Stream{Dict: d, Raw: b[start : start+n]}, nil + } + } + j := bytes.Index(b[start:], []byte("endstream")) + if j < 0 { + return nil, &SyntaxError{kwPos, "unterminated stream"} + } + end := start + j + p.lex.pos = end + len("endstream") + // The end-of-line that precedes endstream belongs to the file, not to the + // stream's data. + if end > start && b[end-1] == '\n' { + end-- + } + if end > start && b[end-1] == '\r' { + end-- + } + return &Stream{Dict: d, Raw: b[start:end]}, nil +} + +// streamLength reads /Length, following an indirect reference when it can. +func (p *parser) streamLength(d Dict) (int, bool) { + o, err := Resolve(d.Get("Length"), p.resolve) + if err != nil { + return 0, false + } + n, ok := ToInt(o) + if !ok || n < 0 { + return 0, false + } + return int(n), true +} + +// endstreamAt reports the offset just past an endstream keyword that follows +// white-space at i, or -1 when something else is there. +func endstreamAt(b []byte, i int) int { + for i < len(b) && isSpace(b[i]) { + i++ + } + if bytes.HasPrefix(b[i:], []byte("endstream")) { + return i + len("endstream") + } + return -1 +} + +// parseObject reads one object. +func (p *parser) parseObject() (Object, error) { + t, err := p.lex.next() + if err != nil { + return nil, err + } + return p.parseFrom(t) +} + +// parseFrom reads the object that starts with an already-read token. +func (p *parser) parseFrom(t token) (Object, error) { + switch t.kind { + case tokEOF: + return nil, &SyntaxError{t.pos, "unexpected end of input"} + case tokInteger: + return p.maybeRef(t) + case tokReal: + return Real(t.f), nil + case tokString: + return String(t.text), nil + case tokName: + return Name(t.text), nil + case tokArrayOpen: + return p.parseArray() + case tokDictOpen: + return p.parseDict() + case tokKeyword: + switch string(t.text) { + case "true": + return Bool(true), nil + case "false": + return Bool(false), nil + case "null": + return Null{}, nil + } + return nil, &SyntaxError{t.pos, "unexpected keyword " + strconv.Quote(string(t.text))} + } + return nil, &SyntaxError{t.pos, "unexpected token"} +} + +// maybeRef decides between the integer just read and the "N G R" that starts +// the same way. Only two tokens of lookahead separate them. +func (p *parser) maybeRef(t token) (Object, error) { + save := p.lex.pos + if t.i >= 0 { + if t2, err := p.lex.next(); err == nil && t2.kind == tokInteger && t2.i >= 0 { + if t3, err := p.lex.next(); err == nil && t3.kind == tokKeyword && string(t3.text) == "R" { + return Ref{Num: int(t.i), Gen: int(t2.i)}, nil + } + } + } + p.lex.pos = save + return Integer(t.i), nil +} + +// parseArray reads the body of an array, the opening bracket already consumed. +func (p *parser) parseArray() (Object, error) { + arr := Array{} + for { + t, err := p.lex.next() + if err != nil { + return nil, err + } + switch t.kind { + case tokArrayClose: + return arr, nil + case tokEOF: + return nil, &SyntaxError{t.pos, "unterminated array"} + } + o, err := p.parseFrom(t) + if err != nil { + return nil, err + } + arr = append(arr, o) + } +} + +// parseDict reads the body of a dictionary, "<<" already consumed. +func (p *parser) parseDict() (Object, error) { + d := Dict{} + for { + t, err := p.lex.next() + if err != nil { + return nil, err + } + switch t.kind { + case tokDictClose: + return d, nil + case tokEOF: + return nil, &SyntaxError{t.pos, "unterminated dictionary"} + case tokName: + default: + return nil, &SyntaxError{t.pos, "dictionary key is a " + t.kind.describe() + ", not a name"} + } + v, err := p.parseObject() + if err != nil { + return nil, err + } + d[Name(t.text)] = v + } +} + +// describe names a token kind for an error message. +func (k tokKind) describe() string { + switch k { + case tokInteger, tokReal: + return "number" + case tokString: + return "string" + case tokArrayOpen, tokArrayClose: + return "bracket" + case tokDictOpen, tokDictClose: + return "dictionary delimiter" + case tokBraceOpen, tokBraceClose: + return "brace" + case tokKeyword: + return "keyword" + } + return "token" +} diff --git a/vendor/github.com/go-pdfkit/reader/predictor.go b/vendor/github.com/go-pdfkit/reader/predictor.go new file mode 100644 index 0000000..21725a9 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/predictor.go @@ -0,0 +1,130 @@ +package reader + +import "fmt" + +// applyPredictor undoes the PNG or TIFF predictor a Flate or LZW stream was +// filtered through. /Predictor 1, and an absent parameter dictionary, mean +// there is nothing to undo. +func applyPredictor(data []byte, parm Dict, r Resolver) ([]byte, error) { + pred := intParm(parm, "Predictor", 1, r) + if pred <= 1 { + return data, nil + } + colors := intParm(parm, "Colors", 1, r) + bpc := intParm(parm, "BitsPerComponent", 8, r) + columns := intParm(parm, "Columns", 1, r) + if colors < 1 || bpc < 1 || columns < 1 { + return nil, fmt.Errorf("reader: predictor: /Colors %d /BitsPerComponent %d /Columns %d", colors, bpc, columns) + } + switch { + case pred == 2: + return tiffPredictor(data, colors, bpc, columns) + case pred >= 10: + return pngPredictor(data, colors, bpc, columns) + } + return nil, fmt.Errorf("reader: predictor: /Predictor %d is not defined", pred) +} + +// rowGeometry gives the bytes per row and the byte distance between a sample +// and the one to its left, both rounded up as the specification requires. +func rowGeometry(colors, bpc, columns int) (rowLen, bpp int) { + return (colors*bpc*columns + 7) / 8, (colors*bpc + 7) / 8 +} + +// pngPredictor undoes the per-row filters PNG defines, each row of the stream +// being preceded by its filter type byte. +func pngPredictor(data []byte, colors, bpc, columns int) ([]byte, error) { + rowLen, bpp := rowGeometry(colors, bpc, columns) + prev := make([]byte, rowLen) + cur := make([]byte, rowLen) + out := make([]byte, 0, len(data)) + for i := 0; i < len(data); { + ft := data[i] + i++ + n := copy(cur, data[i:]) + // A damaged file can end mid-row; treat the missing bytes as zero + // rather than dropping the row. + clear(cur[n:]) + i += n + switch ft { + case 0: + case 1: + for k := bpp; k < rowLen; k++ { + cur[k] += cur[k-bpp] + } + case 2: + for k := 0; k < rowLen; k++ { + cur[k] += prev[k] + } + case 3: + for k := 0; k < rowLen; k++ { + left := 0 + if k >= bpp { + left = int(cur[k-bpp]) + } + cur[k] += byte((left + int(prev[k])) / 2) + } + case 4: + for k := 0; k < rowLen; k++ { + var left, upLeft byte + if k >= bpp { + left, upLeft = cur[k-bpp], prev[k-bpp] + } + cur[k] += paeth(left, prev[k], upLeft) + } + default: + return nil, fmt.Errorf("reader: predictor: unknown PNG row filter %d", ft) + } + out = append(out, cur...) + copy(prev, cur) + } + return out, nil +} + +// paeth is the PNG predictor of that name: the neighbour closest to a+b-c. +func paeth(a, b, c byte) byte { + p := int(a) + int(b) - int(c) + pa, pb, pc := abs(p-int(a)), abs(p-int(b)), abs(p-int(c)) + if pa <= pb && pa <= pc { + return a + } + if pb <= pc { + return b + } + return c +} + +// abs is the integer absolute value. +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// tiffPredictor undoes horizontal differencing. Only the 8- and 16-bit +// component depths are implemented, which is every one seen in practice; a +// sub-byte depth is reported rather than silently mis-decoded. +func tiffPredictor(data []byte, colors, bpc, columns int) ([]byte, error) { + if bpc != 8 && bpc != 16 { + return nil, fmt.Errorf("reader: predictor: TIFF differencing with /BitsPerComponent %d is not supported", bpc) + } + rowLen, bpp := rowGeometry(colors, bpc, columns) + out := append([]byte{}, data...) + for r := 0; r+rowLen <= len(out); r += rowLen { + row := out[r : r+rowLen] + if bpc == 8 { + for k := bpp; k < rowLen; k++ { + row[k] += row[k-bpp] + } + continue + } + for k := bpp; k+1 < rowLen; k += 2 { + v := uint16(row[k])<<8 | uint16(row[k+1]) + p := uint16(row[k-bpp])<<8 | uint16(row[k-bpp+1]) + v += p + row[k], row[k+1] = byte(v>>8), byte(v) + } + } + return out, nil +} diff --git a/vendor/github.com/go-pdfkit/reader/repair.go b/vendor/github.com/go-pdfkit/reader/repair.go new file mode 100644 index 0000000..4a628d8 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/repair.go @@ -0,0 +1,339 @@ +package reader + +import ( + "bytes" + "cmp" + "fmt" + "slices" +) + +// repair rebuilds the cross-reference information by reading the file itself +// rather than what it says about itself: every "N G obj" header in order, the +// later definition of an object number winning, plus whatever trailer or +// catalogue can be found. Damaged files are the normal case for a reader, not +// an exceptional one. +func (d *Document) repair() error { + d.repaired = true + d.cache = map[int]Object{} + d.objStms = map[int]map[int]Object{} + d.pages = nil + d.xref = map[int]xrefEntry{} + + for _, h := range scanObjectHeaders(d.buf) { + // A later definition wins, so the entry is overwritten unconditionally + // rather than through setEntry. + d.xref[h.num] = xrefEntry{kind: 'n', offset: int64(h.offset), gen: h.gen} + } + if len(d.xref) == 0 { + return fmt.Errorf("reader: the file holds no indirect objects") + } + // A rebuild reads object streams, and in an encrypted file it cannot read + // one until the file key is known. The trailers a scan can see are where + // /Encrypt is named, so the key is established before the streams are + // indexed rather than after: otherwise a file whose catalogue lives in an + // encrypted object stream is rebuilt from bytes nobody can read, and the + // rebuild fails for a reason that has nothing to do with the file. + if err := d.establishDecryption(); err != nil { + return err + } + // Objects held in object streams are invisible to a header scan; take them + // from every object stream the scan did find. + d.indexObjectStreams() + d.loadRepairedTrailer() + if d.trailer == nil { + return fmt.Errorf("reader: no document catalogue found") + } + return nil +} + +// establishDecryption derives the file key from a trailer the scan can see, for +// a rebuild that runs before the cross-reference tables could name /Encrypt. +// A file that is not encrypted, or whose key is already known, needs nothing. +func (d *Document) establishDecryption() error { + if d.decrypt != nil || !bytes.Contains(d.buf, []byte("/Encrypt")) { + // /Encrypt can only be named by a trailer, and no trailer is + // compressed, so a file that does not hold those bytes anywhere is not + // encrypted. Saying so cheaply keeps the scan below off the common path. + return nil + } + previous := d.trailer + for _, tr := range d.trailerCandidates() { + if tr == nil || tr.Get("Encrypt").Kind() == KindNull { + continue + } + d.trailer = tr + err := d.setUpDecryption(d.password) + d.trailer = previous + if err != nil { + return err + } + // Whatever was read while the key was unknown was read wrong. + d.cache = map[int]Object{} + return nil + } + return nil +} + +// trailerCandidates lists every dictionary that could carry trailer entries: +// what the tables managed to read, then the file's trailer keywords, then the +// dictionary of each cross-reference stream. +// +// The last of those is not optional. A file written with cross-reference +// streams has no trailer keyword anywhere in it, and /Encrypt then exists only +// in a /Type /XRef stream's dictionary — which is the one stream a PDF never +// encrypts, precisely so that it can be read before the key is known. +// +// The cross-reference streams come highest object number first, an incremental +// update having appended both its new objects and its new table to the file. +func (d *Document) trailerCandidates() []Dict { + out := append([]Dict{d.trailer}, scanTrailers(d.buf)...) + + nums := make([]int, 0, len(d.xref)) + for num := range d.xref { + nums = append(nums, num) + } + slices.Sort(nums) + var streams []Dict + for _, num := range nums { + // An error here leaves a nil object, which is not a stream; the + // rebuild has already been told about anything unreadable. + o, _ := d.Get(Ref{Num: num}) + s, ok := ToStream(o) + if !ok { + continue + } + if t, ok := ToName(s.Dict.Get("Type")); ok && t == "XRef" { + streams = append(streams, s.Dict) + } + } + slices.Reverse(streams) + return append(out, streams...) +} + +// loadRepairedTrailer finds a trailer that leads to a catalogue: the file's own +// trailer dictionaries first, newest first; then any object that calls itself a +// catalogue; and failing both, a catalogue built over whatever pages survive. +func (d *Document) loadRepairedTrailer() { + previous := d.trailer + for _, tr := range scanTrailers(d.buf) { + d.trailer = tr + if _, err := d.Catalog(); err == nil { + return + } + } + if previous != nil { + d.trailer = previous + if _, err := d.Catalog(); err == nil { + return + } + } + d.trailer = nil + for _, num := range d.objectsOfType("Catalog") { + d.trailer = Dict{"Root": Ref{Num: num}} + if _, err := d.Catalog(); err == nil { + return + } + d.trailer = nil + } + d.synthesiseCatalogue() +} + +// objectsOfType lists, in object-number order, the objects whose /Type is the +// given name. +func (d *Document) objectsOfType(want Name) []int { + // The objects that exist are walked, in order, rather than every number + // up to the largest of them. A file may name any object number it likes: + // a 219-byte one in the wild declares object 2147483647, and counting up + // to that is two thousand million map lookups for three objects — which + // is a quarter of a minute of somebody's afternoon for a file that fits + // in a tweet. + nums := make([]int, 0, len(d.xref)) + for num := range d.xref { + nums = append(nums, num) + } + slices.Sort(nums) + var out []int + for _, num := range nums { + o, err := d.Get(Ref{Num: num}) + if err != nil { + continue + } + dict, ok := ToDict(o) + if !ok { + continue + } + if t, ok := ToName(dict.Get("Type")); ok && t == want { + out = append(out, num) + } + } + return out +} + +// synthesiseCatalogue is the last resort for a file whose catalogue and page +// tree did not survive: build one over whatever objects still call themselves +// pages. A truncated file that kept its pages is worth opening; one that kept +// none is not a document. +func (d *Document) synthesiseCatalogue() { + var kids Array + for _, num := range d.objectsOfType("Page") { + kids = append(kids, Ref{Num: num}) + } + if len(kids) == 0 { + return + } + d.trailer = Dict{"Root": Dict{ + "Type": Name("Catalog"), + "Pages": Dict{ + "Type": Name("Pages"), + "Kids": kids, + "Count": Integer(len(kids)), + }, + }} +} + +// indexObjectStreams adds the objects held inside every object stream the scan +// found, without overwriting an object written directly in the file. +func (d *Document) indexObjectStreams() { + // Two object streams in a damaged file may both claim the same object + // number — a partial rewrite leaves the old one in place beside the new — + // and the loop below lets the first stream walked define it. Walking + // d.xref in map order therefore made the same file read differently from + // one run to the next. + // + // They are walked latest in the file first, which is the rule the header + // scan above already applies to objects written directly: a later + // definition wins, an incremental update having appended it. + nums := make([]int, 0, len(d.xref)) + for num := range d.xref { + nums = append(nums, num) + } + slices.Sort(nums) + type located struct { + offset int64 + num int + } + var found []located + for _, num := range nums { + o, err := d.Get(Ref{Num: num}) + if err != nil { + continue + } + s, ok := ToStream(o) + if !ok { + continue + } + if t, ok := ToName(s.Dict.Get("Type")); ok && t == "ObjStm" { + found = append(found, located{d.xref[num].offset, num}) + } + } + slices.SortFunc(found, func(a, b located) int { return cmp.Compare(b.offset, a.offset) }) + streams := make([]int, 0, len(found)) + for _, f := range found { + streams = append(streams, f.num) + } + for _, num := range streams { + // The stream object is already cached by the pass above, so this + // cannot fail; an empty result simply contributes nothing. + objs, _ := d.objectStream(num) + for objNum, obj := range objs { + if _, ok := d.xref[objNum]; ok { + continue + } + d.xref[objNum] = xrefEntry{kind: 'o', strmNum: num} + d.cache[objNum] = obj + } + } +} + +// An objectHeader is one "N G obj" found by scanning. +type objectHeader struct { + num, gen, offset int +} + +// scanObjectHeaders finds every indirect object header in the file, in the +// order they appear. +func scanObjectHeaders(b []byte) []objectHeader { + var out []objectHeader + for i := 0; i+3 <= len(b); { + j := bytes.Index(b[i:], []byte("obj")) + if j < 0 { + return out + } + at := i + j + i = at + 3 + if at+3 < len(b) && isRegular(b[at+3]) { + continue + } + h, ok := headerBefore(b, at) + if !ok { + continue + } + out = append(out, h) + } + return out +} + +// headerBefore reads the "N G" that must precede an obj keyword at at. +func headerBefore(b []byte, at int) (objectHeader, bool) { + p := skipSpaceBack(b, at) + if p == at { + return objectHeader{}, false + } + gen, p, ok := digitsBack(b, p) + if !ok { + return objectHeader{}, false + } + q := skipSpaceBack(b, p) + if q == p { + return objectHeader{}, false + } + num, p, ok := digitsBack(b, q) + if !ok { + return objectHeader{}, false + } + return objectHeader{num: num, gen: gen, offset: p}, true +} + +// skipSpaceBack walks back over white-space and returns the new position. +func skipSpaceBack(b []byte, p int) int { + for p > 0 && isSpace(b[p-1]) { + p-- + } + return p +} + +// digitsBack reads a decimal number that ends at p, going backwards. +func digitsBack(b []byte, p int) (int, int, bool) { + end := p + for p > 0 && b[p-1] >= '0' && b[p-1] <= '9' { + p-- + } + if p == end || end-p > 10 { + return 0, p, false + } + v := 0 + for _, c := range b[p:end] { + v = v*10 + int(c-'0') + } + return v, p, true +} + +// scanTrailers finds the file's trailer dictionaries, newest first. +func scanTrailers(b []byte) []Dict { + var out []Dict + for i := len(b); i > 0; { + j := bytes.LastIndex(b[:i], []byte("trailer")) + if j < 0 { + break + } + i = j + o, _, err := ParseObject(b[j+len("trailer"):]) + if err != nil { + continue + } + if tr, ok := ToDict(o); ok { + out = append(out, tr) + } + } + return out +} diff --git a/vendor/github.com/go-pdfkit/reader/writer.go b/vendor/github.com/go-pdfkit/reader/writer.go new file mode 100644 index 0000000..616cd34 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/writer.go @@ -0,0 +1,432 @@ +package reader + +import ( + "bytes" + "fmt" + "math" + "sort" + "strconv" +) + +// AppendObject writes an object in PDF syntax, appending to dst. Dictionary +// keys are written in order, so the same object always produces the same +// bytes — which is what makes a rewritten file comparable with the one it came +// from. +func AppendObject(dst []byte, o Object) []byte { + switch v := o.(type) { + case nil: + return append(dst, "null"...) + case Null: + return append(dst, "null"...) + case Bool: + if v { + return append(dst, "true"...) + } + return append(dst, "false"...) + case Integer: + return strconv.AppendInt(dst, int64(v), 10) + case Real: + return appendReal(dst, float64(v)) + case String: + return appendString(dst, v) + case Name: + return appendName(dst, v) + case Ref: + dst = strconv.AppendInt(dst, int64(v.Num), 10) + dst = append(dst, ' ') + dst = strconv.AppendInt(dst, int64(v.Gen), 10) + return append(dst, " R"...) + case Array: + dst = append(dst, '[') + for i, e := range v { + if i > 0 { + dst = append(dst, ' ') + } + dst = AppendObject(dst, e) + } + return append(dst, ']') + case Dict: + return appendDict(dst, v) + case *Stream: + return appendStream(dst, v) + } + return append(dst, "null"...) +} + +// FormatObject renders an object in PDF syntax. +func FormatObject(o Object) []byte { return AppendObject(nil, o) } + +// appendReal writes a number the way PDF spells one: no exponent, since the +// format has no notation for it, and no infinity or not-a-number, which it +// cannot represent at all. +func appendReal(dst []byte, f float64) []byte { + if math.IsNaN(f) || math.IsInf(f, 0) { + return append(dst, '0') + } + if f == 0 { + // Negative zero is legal and meaningless, and writing it back out + // would be read as the integer zero, so a rewrite would not be a + // fixpoint. Producers do emit it. + return append(dst, '0') + } + return strconv.AppendFloat(dst, f, 'f', -1, 64) +} + +// appendString writes a string in whichever of the two forms a PDF allows is +// shorter. A literal string spells an unprintable byte as a four-character +// octal escape, so text in UTF-16 — which is how a PDF says anything that is +// not Latin-1 — comes out four times its length; the hex form costs two +// characters a byte whatever the byte is. +func appendString(dst []byte, s []byte) []byte { + if literalStringCost(s) > 2*len(s)+2 { + return appendHexString(dst, s) + } + return appendLiteralString(dst, s) +} + +// literalStringCost is how many characters the literal form would take. +func literalStringCost(s []byte) int { + n := 2 + for _, c := range s { + switch { + case c == '(' || c == ')' || c == '\\' || c == '\n' || c == '\r' || c == '\t': + n += 2 + case c < 32 || c > 126: + n += 4 + default: + n++ + } + } + return n +} + +// appendHexString writes the <hex> form. +func appendHexString(dst []byte, s []byte) []byte { + const hex = "0123456789ABCDEF" + dst = append(dst, '<') + for _, c := range s { + dst = append(dst, hex[c>>4], hex[c&15]) + } + return append(dst, '>') +} + +// appendLiteralString writes the (parenthesised) form, escaping only what has +// to be escaped and rendering anything unprintable as an octal escape. +func appendLiteralString(dst []byte, s []byte) []byte { + dst = append(dst, '(') + for _, c := range s { + switch { + case c == '(' || c == ')' || c == '\\': + dst = append(dst, '\\', c) + case c == '\n': + dst = append(dst, '\\', 'n') + case c == '\r': + dst = append(dst, '\\', 'r') + case c == '\t': + dst = append(dst, '\\', 't') + case c < 32 || c > 126: + dst = append(dst, '\\') + dst = append(dst, '0'+c>>6&7, '0'+c>>3&7, '0'+c&7) + default: + dst = append(dst, c) + } + } + return append(dst, ')') +} + +// appendName writes a name, escaping every byte that may not appear in one. +func appendName(dst []byte, n Name) []byte { + dst = append(dst, '/') + const hex = "0123456789ABCDEF" + for i := 0; i < len(n); i++ { + c := n[i] + if !isRegular(c) || c == '#' || c < '!' || c > '~' { + dst = append(dst, '#', hex[c>>4], hex[c&15]) + continue + } + dst = append(dst, c) + } + return dst +} + +// appendDict writes a dictionary with its keys in order. +func appendDict(dst []byte, d Dict) []byte { + keys := make([]string, 0, len(d)) + for k := range d { + keys = append(keys, string(k)) + } + sort.Strings(keys) + dst = append(dst, "<<"...) + for _, k := range keys { + dst = appendName(dst, Name(k)) + dst = append(dst, ' ') + dst = AppendObject(dst, d[Name(k)]) + dst = append(dst, ' ') + } + if len(keys) > 0 { + dst = dst[:len(dst)-1] + } + return append(dst, ">>"...) +} + +// appendStream writes a stream, its /Length rewritten to the length of the +// data actually being written. +func appendStream(dst []byte, s *Stream) []byte { + d := Dict{} + for k, v := range s.Dict { + d[k] = v + } + d["Length"] = Integer(len(s.Raw)) + dst = appendDict(dst, d) + dst = append(dst, "\nstream\n"...) + dst = append(dst, s.Raw...) + return append(dst, "\nendstream"...) +} + +// A Writer builds a PDF file out of objects. Numbers are handed out by +// [Writer.Reserve], objects are written with [Writer.Put], and [Writer.Finish] +// adds the cross-reference table and the trailer. +type Writer struct { + buf bytes.Buffer + offsets map[int]int + packed map[int]packedAt + pending []pendingObject + seen map[int]bool + next int + copied map[*Document]map[int]Ref + err error + pack bool + encrypt *encrypter +} + +// NewWriter starts a file with the given version in its header, "1.7" when the +// version is empty. Objects are written one after another and the file ends in +// a cross-reference table, which every reader that has ever existed can read. +func NewWriter(version string) *Writer { return newWriter(version, false) } + +// NewPackedWriter starts a file that puts what it can into compressed object +// streams and ends in a cross-reference stream. That is smaller — usually by +// a lot, since a file is mostly small dictionaries — and has been readable +// since PDF 1.5, which is 2003; the version in the header is raised to 1.5 if +// it is lower. +func NewPackedWriter(version string) *Writer { return newWriter(version, true) } + +// newWriter starts a file of either kind. +func newWriter(version string, pack bool) *Writer { + if version == "" { + version = "1.7" + } + if pack && version < "1.5" { + version = "1.5" + } + w := &Writer{ + offsets: map[int]int{}, + packed: map[int]packedAt{}, + seen: map[int]bool{}, + next: 1, + copied: map[*Document]map[int]Ref{}, + pack: pack, + } + fmt.Fprintf(&w.buf, "%%PDF-%s\n", version) + // The four bytes above 127 tell every tool downstream that this file is + // not text, which is what the specification asks for. + w.buf.Write([]byte{'%', 0xE2, 0xE3, 0xCF, 0xD3, '\n'}) + return w +} + +// Reserve hands out an object number that nothing has been written to yet. +func (w *Writer) Reserve() Ref { + r := Ref{Num: w.next} + w.next++ + return r +} + +// Put writes an object under a reserved number. +func (w *Writer) Put(ref Ref, o Object) { + if w.seen[ref.Num] { + w.note(fmt.Errorf("reader: object %d written twice", ref.Num)) + return + } + w.seen[ref.Num] = true + if ref.Num >= w.next { + w.next = ref.Num + 1 + } + // A stream cannot go inside another stream, and only generation zero may + // be packed; everything else can wait to be gathered up at the end. + if _, isStream := o.(*Stream); w.pack && !isStream && ref.Gen == 0 { + w.pending = append(w.pending, pendingObject{ref: ref, obj: o}) + return + } + w.writeInline(ref, o) +} + +// writeInline writes an object where it stands in the file, compressed and +// then encrypted — in that order, since encrypted bytes do not compress. +func (w *Writer) writeInline(ref Ref, o Object) { + o = w.compressStream(o) + if w.encrypt != nil { + o = w.encrypt.apply(ref.Num, ref.Gen, o) + } + w.emit(ref, o) +} + +// writeInlineRaw writes an object that must not be encrypted: the /Encrypt +// dictionary itself, and the cross-reference stream, neither of which a +// reader could decrypt, since it needs them to work out how. +func (w *Writer) writeInlineRaw(ref Ref, o Object) { + w.emit(ref, w.compressStream(o)) +} + +// compressStream deflates a stream that arrives with no filter of its own, +// when the file is being written to be small. +func (w *Writer) compressStream(o Object) Object { + s, ok := o.(*Stream) + if !ok || !w.pack || s.Dict.Get("Filter").Kind() != KindNull { + return o + } + // Nothing this package generated arrives compressed. + out := &Stream{Dict: cloneDict(s.Dict), Raw: flateCompress(s.Raw)} + out.Dict["Filter"] = Name("FlateDecode") + return out +} + +// emit writes an object and remembers where it went. +func (w *Writer) emit(ref Ref, o Object) { + w.offsets[ref.Num] = w.buf.Len() + fmt.Fprintf(&w.buf, "%d %d obj\n", ref.Num, ref.Gen) + w.buf.Write(AppendObject(nil, o)) + w.buf.WriteString("\nendobj\n") +} + +// cloneDict copies a dictionary one level deep. +func cloneDict(d Dict) Dict { + out := Dict{} + for k, v := range d { + out[k] = v + } + return out +} + +// Add reserves a number, writes the object under it and returns the reference. +func (w *Writer) Add(o Object) Ref { + ref := w.Reserve() + w.Put(ref, o) + return ref +} + +// Err reports the first thing that went wrong while building the file. +func (w *Writer) Err() error { return w.err } + +// note keeps the first error. +func (w *Writer) note(err error) { + if w.err == nil { + w.err = err + } +} + +// Finish writes the cross-reference table and the trailer and returns the +// file. /Size is filled in; the caller supplies /Root and whatever else the +// trailer needs. +func (w *Writer) Finish(trailer Dict) ([]byte, error) { + // The caller's dictionary is left as it was; what a file needs to say + // about its own encryption is added to a copy. + trailer = cloneDict(trailer) + for k, v := range w.writeEncryptDict() { + trailer[k] = v + } + if w.pack { + return w.finishWithXrefStream(trailer) + } + high := 0 + for num := range w.offsets { + if num > high { + high = num + } + } + start := w.buf.Len() + fmt.Fprintf(&w.buf, "xref\n0 %d\n0000000000 65535 f \n", high+1) + for num := 1; num <= high; num++ { + off, ok := w.offsets[num] + if !ok { + w.buf.WriteString("0000000000 65535 f \n") + continue + } + fmt.Fprintf(&w.buf, "%010d 00000 n \n", off) + } + out := Dict{} + for k, v := range trailer { + out[k] = v + } + out["Size"] = Integer(high + 1) + w.buf.WriteString("trailer\n") + w.buf.Write(AppendObject(nil, out)) + fmt.Fprintf(&w.buf, "\nstartxref\n%d\n%%%%EOF\n", start) + if w.encrypt != nil && w.encrypt.err != nil { + w.note(w.encrypt.err) + } + if w.err != nil { + return nil, w.err + } + return w.buf.Bytes(), nil +} + +// Copy writes an object from a document into this file, following every +// indirect reference it reaches and renumbering as it goes, so that objects +// from several documents can live side by side. An object already copied from +// the same document keeps the number it was given. +func (w *Writer) Copy(d *Document, o Object) Object { + seen, ok := w.copied[d] + if !ok { + seen = map[int]Ref{} + w.copied[d] = seen + } + return w.copyObject(d, seen, o, 0) +} + +// copyObject is Copy's recursion. +func (w *Writer) copyObject(d *Document, seen map[int]Ref, o Object, depth int) Object { + if depth > maxCopyDepth { + w.note(fmt.Errorf("reader: an object graph nested deeper than %d was truncated", maxCopyDepth)) + return Null{} + } + switch v := o.(type) { + case Ref: + if to, ok := seen[v.Num]; ok { + return to + } + // The number is reserved before the object is read, so a reference + // back to an ancestor finds it rather than recursing. + to := w.Reserve() + seen[v.Num] = to + src, err := d.Get(v) + if err != nil { + w.note(err) + src = Null{} + } + w.Put(to, w.copyObject(d, seen, src, depth+1)) + return to + case Array: + out := make(Array, len(v)) + for i, e := range v { + out[i] = w.copyObject(d, seen, e, depth+1) + } + return out + case Dict: + out := Dict{} + for k, e := range v { + out[k] = w.copyObject(d, seen, e, depth+1) + } + return out + case *Stream: + out := &Stream{Dict: Dict{}, Raw: v.Raw} + for k, e := range v.Dict { + out.Dict[k] = w.copyObject(d, seen, e, depth+1) + } + return out + } + return o +} + +// maxCopyDepth bounds a copy, since a direct object may nest arbitrarily even +// when no reference repeats. +const maxCopyDepth = 256 diff --git a/vendor/github.com/go-pdfkit/reader/xref.go b/vendor/github.com/go-pdfkit/reader/xref.go new file mode 100644 index 0000000..9b5b574 --- /dev/null +++ b/vendor/github.com/go-pdfkit/reader/xref.go @@ -0,0 +1,303 @@ +package reader + +import ( + "bytes" + "fmt" +) + +// An xrefEntry says where object N lives. +type xrefEntry struct { + kind byte // 'n' at a byte offset, 'o' inside an object stream, 'f' free + offset int64 // kind 'n' + gen int // kind 'n' + strmNum int // kind 'o': the object stream's own number + strmIdx int // kind 'o': the index within it +} + +// maxXrefSections bounds a /Prev chain, which a file is free to make a cycle. +const maxXrefSections = 1024 + +// startxrefWindow is how far back from the end the startxref keyword is looked +// for. The specification allows only a short tail; real files sometimes append +// junk, so the window is generous. +const startxrefWindow = 4096 + +// findStartxref reads the offset the file's last startxref names. +func (d *Document) findStartxref() (int64, error) { + from := len(d.buf) - startxrefWindow + if from < 0 { + from = 0 + } + i := bytes.LastIndex(d.buf[from:], []byte("startxref")) + if i < 0 { + return 0, fmt.Errorf("reader: no startxref in the last %d bytes", startxrefWindow) + } + l := &lexer{buf: d.buf, pos: from + i + len("startxref")} + t, err := l.next() + if err != nil { + return 0, err + } + if t.kind != tokInteger || t.i < 0 || t.i >= int64(len(d.buf)) { + return 0, &SyntaxError{t.pos, "startxref does not name an offset inside the file"} + } + return t.i, nil +} + +// loadXref follows the chain of cross-reference sections from the last one +// back through /Prev, taking the newest definition of every object and of +// every trailer entry. +func (d *Document) loadXref() error { + off, err := d.findStartxref() + if err != nil { + return err + } + seen := map[int64]bool{} + for n := 0; off > 0 && n < maxXrefSections; n++ { + if seen[off] { + return fmt.Errorf("reader: cross-reference sections form a cycle at offset %d", off) + } + seen[off] = true + tr, err := d.readXrefSection(off) + if err != nil { + return err + } + d.mergeTrailer(tr) + // A hybrid file keeps a second, stream-shaped section for the objects + // its table deliberately hides from old readers. + if x, ok := ToInt(tr.Get("XRefStm")); ok && x > 0 && !seen[x] { + seen[x] = true + if hy, err := d.readXrefSection(x); err == nil { + d.mergeTrailer(hy) + } + } + p, ok := ToInt(tr.Get("Prev")) + if !ok || p <= 0 { + break + } + off = p + } + if len(d.xref) == 0 { + return fmt.Errorf("reader: the cross-reference table is empty") + } + return nil +} + +// mergeTrailer keeps the first value seen for each key, sections being read +// newest first. +func (d *Document) mergeTrailer(tr Dict) { + if d.trailer == nil { + d.trailer = Dict{} + } + for k, v := range tr { + if _, ok := d.trailer[k]; !ok { + d.trailer[k] = v + } + } +} + +// setEntry records an entry unless a newer section already defined the object. +func (d *Document) setEntry(num int, e xrefEntry) { + if num < 0 { + return + } + if _, ok := d.xref[num]; !ok { + d.xref[num] = e + } +} + +// readXrefSection reads whichever of the two forms is at off and returns its +// trailer dictionary. +func (d *Document) readXrefSection(off int64) (Dict, error) { + if off < 0 || off >= int64(len(d.buf)) { + return nil, fmt.Errorf("reader: cross-reference offset %d is outside the file", off) + } + l := &lexer{buf: d.buf, pos: int(off)} + t, err := l.next() + if err != nil { + return nil, err + } + if t.kind == tokKeyword && string(t.text) == "xref" { + return d.readXrefTable(l) + } + _, obj, _, err := ParseIndirectObject(d.buf[off:], nil) + if err != nil { + return nil, err + } + s, ok := ToStream(obj) + if !ok { + return nil, &SyntaxError{int(off), "neither an xref table nor an xref stream"} + } + return d.readXrefStream(s) +} + +// readXrefTable reads the classic subsection form, the xref keyword already +// consumed, up to and including its trailer dictionary. +func (d *Document) readXrefTable(l *lexer) (Dict, error) { + p := &parser{lex: *l} + for { + save := p.lex.pos + t, err := p.lex.next() + if err != nil { + return nil, err + } + if t.kind == tokKeyword && string(t.text) == "trailer" { + o, err := p.parseObject() + if err != nil { + return nil, err + } + tr, ok := ToDict(o) + if !ok { + return nil, &SyntaxError{save, "the trailer is not a dictionary"} + } + return tr, nil + } + if t.kind != tokInteger { + return nil, &SyntaxError{t.pos, "a cross-reference subsection header was expected"} + } + start := int(t.i) + count, err := p.expectUint("a subsection entry count") + if err != nil { + return nil, err + } + for i := 0; i < count; i++ { + if err := d.readXrefTableEntry(p, start+i); err != nil { + return nil, err + } + } + } +} + +// readXrefTableEntry reads one "offset generation n|f" line. +func (d *Document) readXrefTableEntry(p *parser, num int) error { + t1, err := p.lex.next() + if err != nil { + return err + } + if t1.kind != tokInteger { + return &SyntaxError{t1.pos, "a cross-reference entry offset was expected"} + } + t2, err := p.lex.next() + if err != nil { + return err + } + if t2.kind != tokInteger { + return &SyntaxError{t2.pos, "a cross-reference entry generation was expected"} + } + t3, err := p.lex.next() + if err != nil { + return err + } + if t3.kind != tokKeyword || len(t3.text) != 1 || (t3.text[0] != 'n' && t3.text[0] != 'f') { + return &SyntaxError{t3.pos, "a cross-reference entry must end in n or f"} + } + if t3.text[0] == 'n' { + d.setEntry(num, xrefEntry{kind: 'n', offset: t1.i, gen: int(t2.i)}) + return nil + } + d.setEntry(num, xrefEntry{kind: 'f'}) + return nil +} + +// readXrefStream reads the /Type /XRef stream form. Its own data is never +// encrypted, so it is decoded without the document's decryptor. +func (d *Document) readXrefStream(s *Stream) (Dict, error) { + data, img, err := Decode(s.Dict, s.Raw, nil) + if err != nil { + return nil, err + } + if img != "" { + return nil, fmt.Errorf("reader: the cross-reference stream is filtered as an image") + } + widths, err := xrefWidths(s.Dict) + if err != nil { + return nil, err + } + index, err := xrefIndex(s.Dict) + if err != nil { + return nil, err + } + row := widths[0] + widths[1] + widths[2] + if row == 0 { + return nil, fmt.Errorf("reader: the cross-reference stream's /W entries are all zero") + } + pos := 0 + for i := 0; i+1 < len(index); i += 2 { + for k := 0; k < index[i+1]; k++ { + if pos+row > len(data) { + // A section that claims more rows than it carries still + // contributes the rows it does carry. + return s.Dict, nil + } + f1 := xrefField(data[pos:], widths[0], 1) + f2 := xrefField(data[pos+widths[0]:], widths[1], 0) + f3 := xrefField(data[pos+widths[0]+widths[1]:], widths[2], 0) + pos += row + d.storeXrefStreamEntry(index[i]+k, f1, f2, f3) + } + } + return s.Dict, nil +} + +// storeXrefStreamEntry turns one decoded row into an entry. +func (d *Document) storeXrefStreamEntry(num int, f1, f2, f3 int64) { + switch f1 { + case 1: + d.setEntry(num, xrefEntry{kind: 'n', offset: f2, gen: int(f3)}) + case 2: + d.setEntry(num, xrefEntry{kind: 'o', strmNum: int(f2), strmIdx: int(f3)}) + default: + d.setEntry(num, xrefEntry{kind: 'f'}) + } +} + +// xrefWidths reads /W, which says how many bytes each of the three fields +// occupies. +func xrefWidths(d Dict) ([3]int, error) { + var w [3]int + arr, ok := ToArray(d.Get("W")) + if !ok || len(arr) < 3 { + return w, fmt.Errorf("reader: the cross-reference stream has no usable /W") + } + for i := 0; i < 3; i++ { + n, ok := ToInt(arr[i]) + if !ok || n < 0 || n > 8 { + return w, fmt.Errorf("reader: /W[%d] is not a byte width", i) + } + w[i] = int(n) + } + return w, nil +} + +// xrefIndex reads /Index, defaulting to the whole range /Size describes. +func xrefIndex(d Dict) ([]int, error) { + arr, ok := ToArray(d.Get("Index")) + if !ok { + size, ok := ToInt(d.Get("Size")) + if !ok || size < 0 { + return nil, fmt.Errorf("reader: the cross-reference stream has neither /Index nor /Size") + } + return []int{0, int(size)}, nil + } + out := make([]int, 0, len(arr)) + for _, e := range arr { + n, ok := ToInt(e) + if !ok || n < 0 { + return nil, fmt.Errorf("reader: /Index holds a value that is not a count") + } + out = append(out, int(n)) + } + return out, nil +} + +// xrefField reads one big-endian field of the given width, or its default when +// the width is zero. +func xrefField(b []byte, width int, def int64) int64 { + if width == 0 { + return def + } + v := int64(0) + for i := 0; i < width; i++ { + v = v<<8 | int64(b[i]) + } + return v +} diff --git a/vendor/modules.txt b/vendor/modules.txt index e387096..b7bede8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -27,6 +27,18 @@ github.com/cespare/xxhash/v2 # github.com/dustin/go-humanize v1.0.1 ## explicit; go 1.16 github.com/dustin/go-humanize +# github.com/go-opentype/opentype v0.12.0 +## explicit; go 1.26.4 +github.com/go-opentype/opentype +# github.com/go-pdfkit/extract v0.3.0 +## explicit; go 1.26.4 +github.com/go-pdfkit/extract +# github.com/go-pdfkit/pdffont v0.3.0 +## explicit; go 1.26.4 +github.com/go-pdfkit/pdffont +# github.com/go-pdfkit/reader v0.6.0 +## explicit; go 1.26.4 +github.com/go-pdfkit/reader # github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c ## explicit; go 1.20 github.com/go-shiori/dom