Skip to content

[font/opentype] add NewLoadersFromBytes, which does not copy tables - #299

Open
mike-ward wants to merge 3 commits into
go-text:mainfrom
go-gui-org:zero-copy-loader
Open

mike-ward wants to merge 3 commits into
go-text:mainfrom
go-gui-org:zero-copy-loader

Conversation

@mike-ward

@mike-ward mike-ward commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Loader.RawTable copies every table out of the Resource into a new buffer, and a font.Font keeps the tables it parses. For a color emoji font that is most of the file. The sbix table of macOS Apple Color Emoji.ttc is 182 MB, so NewFont on it keeps 193 MB on the Go heap. A terminal that falls back to this font for one emoji grows by that much.

This PR adds NewLoadersFromBytes(data []byte). It is the same as NewLoaders, but it returns each uncompressed table as a sub-slice of data and does not copy it. The input can be a read-only memory mapping of the file. The OS then pages the tables in as they are used, and can drop the pages again under memory pressure.

  • The capacity of each returned table stops at the table end, so if a caller appends to it, the next table is not overwritten.
  • Compressed WOFF tables are still decoded into a new buffer.
  • The dst argument of RawTableTo is not used by such a loader. dst can be a table that an earlier call returned (NewFont and fontscan pass the previous buffer back), and that table is now a view of the input.
  • NewLoaders and its copying behavior do not change.

I checked that nothing in font or tables writes into a table buffer. The only write found, in KernData1.parseValues, goes to a [4]byte array field.

Measured

Apple Color Emoji.ttc, face 0, heap after runtime.GC() with the Font alive:

Loader Heap
os.File + NewLoaders +193 MB
syscall.Mmap + NewLoadersFromBytes +11 MB

U+1F600 still decodes as a PNG GlyphBitmap.

Tests

  • Every table of each common and collections font (this includes a compressed WOFF) reads back the same as with NewLoaders.
  • Each uncompressed table is a view of the input, with cap == len.
  • RawTableTo with the previous table as dst leaves the input unchanged.
  • A truncated input returns errors and does not panic.
  • In font, sbix glyph data from the Sbix toy fonts is the same as with NewLoaders, and it points into the input.

go test ./... passes on Go 1.19.13 and on stable. go vet and staticcheck v0.8.0 are clean.

🤖 Generated with Claude Code

RawTable copies every table out of the Resource into a new buffer, and a
font.Font keeps the tables it parses. For a color emoji font this is most of
the file: the 'sbix' table of macOS Apple Color Emoji.ttc is 182MB, so
NewFont on it keeps 193MB on the Go heap.

NewLoadersFromBytes builds the loaders from a []byte and returns each
uncompressed table as a sub-slice of it, with its capacity limited to the
table end. Compressed WOFF tables are still decoded into a new buffer. The
input can be a read-only memory mapping, so the OS pages the tables in as
they are used and can drop them again under memory pressure.

The dst argument of RawTableTo is not used by such a loader: it may be a
table returned before, which is then a view of the input.

Measured on Apple Color Emoji.ttc, face 0, heap after GC with the Font
alive: os.File + NewLoaders +193MB; mmap + NewLoadersFromBytes +11MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mike-ward and others added 2 commits October 6, 2026 11:07
TestParseCrashers already feeds random input to NewLoaders, which
NewLoadersFromBytes calls. The loop also used math/rand.Read, which
staticcheck flags (SA1019).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…table at EOF

- Doc: the whole input stays reachable while any loader, font or data
  derived from them is in use; it must not be modified or unmapped; a
  returned table must never be passed as dst to a copying loader.
- An empty table at offset == len(data) is now rejected, as ReadAt does on
  the copying path.
- Tests: a compressed WOFF table with a view of the input as dst (fails
  without the dst guard); truncated input must accept and reject the same
  tables as NewLoaders; empty table at EOF. The old dst test is removed: it
  passed without the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@benoitkugler

Copy link
Copy Markdown
Contributor

Hello,

While I sympathize with the issue, I'm not sure I like the proposed solution that much. It seems the issue is that findTableBuffer always allocate (when dst is nil). It is indeed unfortunate when using read-only memory mapping.

Could we instead introduce an interface extending Resource with the following method :
Slice(start, end int) []byte
and change findTableBuffer to rely on such a method if present ?

Then, the new interface is easy to implement if you already have the whole resource as a byte slice.

@mike-ward

Copy link
Copy Markdown
Contributor Author

Thanks, that is cleaner. I'll rework it as an optional interface checked in findTableBuffer. Two questions:

  1. Can Slice return an error, Slice(start, end int64) ([]byte, error)? Then a bad table offset returns an error and does not panic, and the types match ReadAt.
  2. Do you want an exported type that wraps []byte and has Slice, or should callers write their own?

On the slice path, dst will still be ignored (a previous result can be a view of read-only memory), and the returned slice will be capped at the table end.

@benoitkugler

Copy link
Copy Markdown
Contributor

Thanks, that is cleaner. I'll rework it as an optional interface checked in findTableBuffer. Two questions:

  1. Can Slice return an error, Slice(start, end int64) ([]byte, error)? Then a bad table offset returns an error and does not panic, and the types match ReadAt.
  2. Do you want an exported type that wraps []byte and has Slice, or should callers write their own?

On the slice path, dst will still be ignored (a previous result can be a view of read-only memory), and the returned slice will be capped at the table end.

Yes, returning an error should be fine.

I'm not sure about exporting a concrete type wrapping a slice. Maybe that would benefit other users who already have the full slice "in memory" ?

@mike-ward

Copy link
Copy Markdown
Contributor Author

Thanks. I'll add ResourceSlicer (Resource + Slice(start, end int64) ([]byte, error)), checked in findTableBuffer, and drop NewLoadersFromBytes. I'll also export a small NewSliceResource([]byte) wrapper over bytes.Reader, since any mmap or in-memory user needs one. It is easy to remove if you'd rather not have it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants