texmf

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: BSD-3-Clause Imports: 17 Imported by: 0

README

texmf — go-tex

ci License Go Coverage

Gets a TeX engine the support tree a document asks for, on a machine with no TeX distribution installed. Pure Go, no cgo, standard library only.

It redistributes nothing. Every byte of TeX macro source comes from a pinned upstream release, fetched onto the user's own machine, checked against a recorded SHA-256, and kept in the user cache.

That is deliberate. beamer, for one, offers itself "under the LaTeX Project Public License and/or under the GNU Public License" — and a download is not a redistribution, so nothing here has to choose between those two licences on a user's behalf.

Use

tree, err := texmf.Open(ctx, texmf.Beamer, texmf.Options{})
if err == nil {
    opt.Resolve = tree.Resolve // github.com/go-tex/engine Options.Resolve
}

Tree.Resolve has exactly the shape the engine's Options.Resolve wants: a TeX engine asks for beamerbasetitle.sty, never for a path.

A caller that already has the files — a TeX distribution on the machine, a vendored copy, an air-gapped host — does not need this package at all. The engine's own search path and Options.Resolve already take precedence over anything fetched here.

Why it is worth fetching

Measured over 10025 real beamer talks:

documents rendered pages typeset
with beamer.cls 99.9% 87 209 (~8.7 per talk)
engine emulation only 92.3% 14 162 (~1.4 per talk)

The difference is not an edge case; it is most of the content.

How a bundle arrives

Routes are tried in order, and the digest is the only trust anchor — a route is a delivery mechanism, never an authority:

  1. the cache (<user cache>/go-tex/texmf/beamer@3.77), so a second run is offline whatever the options say;
  2. ghcr.io/go-tex/texmf/beamer, a registry this project controls, so the default route does not depend on a third party staying up;
  3. the upstream release, so the bundle stays reachable when the registry is not.

Only the files a TeX engine opens are extracted: tex/latex/beamer/ and nothing else. A TDS archive's doc/ and source/ trees are thousands of files that never reach the disk.

In a browser

Open extracts into the user cache, and Go's js/wasm filesystem shim answers ENOSYS to most calls — so it cannot run in a browser, which is exactly the host Options.Resolve exists to serve. OpenInMemory is the same fetch and the same digest check, keeping the files in memory instead:

tree, err := texmf.OpenInMemory(ctx, texmf.Beamer, texmf.Options{})

OpenInMemory still fetches, and a browser cannot: neither ghcr.io nor the GitHub release sends an Access-Control-Allow-Origin header (measured), so a page is refused by CORS. There the host fetches the bytes its own way — same-origin next to the page, a CDN that does send the header, the Cache API, a bundled asset — and hands them over:

tree, err := texmf.FromArchive(data, texmf.Beamer)  // digest still decides

Under node or wasip1 a real filesystem is bridged in (measured: os.UserCacheDir, os.MkdirTemp and os.WriteFile all succeed under node), so Open works there and is the better choice — it caches. Nothing is cached in memory, so every OpenInMemory call fetches: keep the returned Tree.

Options.Offline forbids every fetch: a cached bundle is used, an absent one returns ErrNotCached. That is what an air-gapped or reproducible build sets.

Tests

go test ./...100% statement coverage, including every filesystem and transport failure, on six 64-bit architectures, three operating systems and both wasm targets.

The suite never touches the network: it runs against a local httptest registry and temporary directories, so it is as deterministic on a laptop as on a runner. One test does use the network and is skipped unless TEXMF_NETWORK is set — CI runs it to check the pinned digests still describe what upstream serves, so a re-cut release is found there rather than by a user with a cold cache.

License

BSD-3-Clause, © the go-tex/texmf authors. The material it fetches carries its own licence, from its own publisher, onto the user's own machine.

Documentation

Overview

Package texmf gets a TeX engine the support tree a document asks for, on a machine with no TeX distribution installed.

It redistributes nothing. Every byte of TeX macro source comes from a pinned upstream release, fetched onto the user's own machine, checked against a recorded SHA-256, and kept in the user cache. That is deliberate: beamer, for one, is offered "under the LaTeX Project Public License and/or under the GNU Public License", and downloading it is not a redistribution, so the choice between those two licences never has to be made on the user's behalf.

The result plugs straight into the engine:

tree, err := texmf.Open(ctx, texmf.Beamer, texmf.Options{})
if err == nil {
	opt.Resolve = tree.Resolve
}

A caller that already has the files — a TeX distribution on the machine, a vendored copy, an air-gapped host — does not need this package at all: the engine's own search path and Options.Resolve already take precedence over anything fetched here.

Index

Constants

This section is empty.

Variables

View Source
var All = map[string]Bundle{
	Beamer.Name:     Beamer,
	PGF.Name:        PGF,
	PGFPlots.Name:   PGFPlots,
	Translator.Name: Translator,
}

All is the catalogue, keyed by bundle name. The publish workflow reads a pin from here rather than repeating it in YAML, so a mirror can never carry a version or digest this code does not name.

View Source
var Beamer = Bundle{
	Name:     "beamer",
	Version:  beamerVersion,
	SHA256:   "2ab4acf4c6be0d96d3f18161b08fd8e56ab54d380686f6440a42705e48c76f76",
	Prefixes: []string{"tex/latex/beamer/"},
	Provides: []string{"beamer", "beamerarticle"},
	Sources: []Source{
		OCISource{
			Registry:   "ghcr.io",
			Repository: "go-tex/texmf/beamer",
			Reference:  beamerVersion,
			Label:      "ghcr.io/go-tex/texmf/beamer:" + beamerVersion,
		},
		HTTPSource{
			URL:   "https://github.com/josephwright/beamer/releases/download/v" + beamerVersion + "/beamer.tds.zip",
			Label: "upstream release josephwright/beamer v" + beamerVersion,
		},
	},
}

Beamer is the beamer presentation class and the files it loads.

Measured over 10025 real talks: with these files present the engine renders 99.9% of them and ~8.7 pages per talk; without them, falling back to the engine's own emulation, 92.3% and ~1.4 pages. The difference is not an edge case, it is most of the content.

The archive is beamer's TDS distribution. Only tex/latex/beamer/ is extracted: the rest is documentation and sources, thousands of files the engine never opens.

Routes, in order:

  1. ghcr.io/go-tex/texmf/beamer — a registry the project controls, so the default route does not depend on a third party staying up;
  2. the upstream release itself, so the bundle stays reachable when the registry is not.

Both must produce the same bytes: SHA256 is checked whichever route ran, so a route is a delivery mechanism and never a trust anchor.

View Source
var ErrNotCached = errors.New("texmf: bundle not cached and offline")

ErrNotCached is returned by Open when the bundle is absent from the cache and Options.Offline forbids fetching it.

View Source
var PGF = Bundle{
	Name:     "pgf",
	Version:  pgfVersion,
	SHA256:   "d243b67705ab4f0e4fe91c4b26ed9da67cd7b0643fc38f158e91b600df9ba15e",
	Prefixes: []string{"tex/generic/pgf/", "tex/latex/pgf/"},

	Provides: []string{
		"tikz", "pgf", "pgfcore", "pgfmath", "pgfkeys", "pgffor", "pgfpages",
		"pgfrcs", "pgfsys", "pgfparser", "pgfcalendar", "pgfpict2e", "pgfnodes",
		"pgfarrows", "pgfautomata", "pgfheaps", "pgfshade", "xxcolor",
		"tikzexternal", "pgfmanual",
		"pgfbaseimage", "pgfbaselayers", "pgfbasematrix", "pgfbasepatterns",
		"pgfbaseplot", "pgfbaseshapes", "pgfbasesnakes",
		"pgflibraryarrows", "pgflibraryautomata", "pgflibraryplothandlers",
		"pgflibraryplotmarks", "pgflibraryshapes", "pgflibrarysnakes",
		"pgflibrarytikzbackgrounds", "pgflibrarytikztrees",
		"pgfcomp-version-0-65", "pgfcomp-version-1-18",
	},
	Sources: []Source{
		OCISource{
			Registry:   "ghcr.io",
			Repository: "go-tex/texmf/pgf",
			Reference:  pgfVersion,
			Label:      "ghcr.io/go-tex/texmf/pgf:" + pgfVersion,
		},
		HTTPSource{
			URL:   "https://github.com/pgf-tikz/pgf/releases/download/" + pgfVersion + "/pgf_" + pgfVersion + ".tds.zip",
			Label: "upstream release pgf-tikz/pgf " + pgfVersion,
		},
	},
}

PGF is pgf and TikZ: the drawing package the engine emulates when it is absent and loads for real when it is present.

Two prefixes, because pgf splits itself the way a package that serves both plain TeX and LaTeX has to: the .sty wrappers live under tex/latex/pgf/ and almost everything else — tikz.code.tex, pgfcore, pgfkeys, the pgfsys drivers — under tex/generic/pgf/. Loading only one half loads nothing.

The .lua files under those trees are dropped on the way out (see readZip): the engine has no Lua interpreter, and they are the only base-name collisions pgf has.

View Source
var PGFPlots = Bundle{
	Name:     "pgfplots",
	Version:  pgfplotsVersion,
	SHA256:   "4c4f33e976ba01d3f635c92d0a697a70c2c8779d5bcaae3b3ec2fbd8c82cc7ce",
	Prefixes: []string{"tex/generic/pgfplots/", "tex/latex/pgfplots/"},

	Provides: []string{"pgfplots", "pgfplotstable"},
	Sources: []Source{
		OCISource{
			Registry:   "ghcr.io",
			Repository: "go-tex/texmf/pgfplots",
			Reference:  pgfplotsVersion,
			Label:      "ghcr.io/go-tex/texmf/pgfplots:" + pgfplotsVersion,
		},
		HTTPSource{
			URL:   "https://github.com/pgf-tikz/pgfplots/releases/download/" + pgfplotsVersion + "/pgfplots_" + pgfplotsVersion + ".tds.zip",
			Label: "upstream release pgf-tikz/pgfplots " + pgfplotsVersion,
		},
	},
}

PGFPlots is pgfplots, which draws function plots on top of pgf and cannot load without it.

It is the single largest thing standing between the engine and a real paper's figures: of the arXiv documents that contain a tikzpicture and draw nothing, more than half use pgfplots, and \addplot is by a wide margin the most common command the engine reports as undefined.

View Source
var Requires = map[string][]string{
	PGFPlots.Name: {PGF.Name},
	Beamer.Name:   {Translator.Name},
}

Requires names the bundles a bundle cannot load without, innermost first. It is a fact about the packages, not about any one caller, so it belongs beside the pins rather than in whatever program happens to fetch them.

View Source
var Translator = Bundle{
	Name:     "translator",
	Version:  translatorVersion,
	SHA256:   "89a7be175e1f8ae1b5ea479a22a7c2c0e02f95a6fb457ac97ffd9eaf7b573eba",
	Prefixes: []string{"translator-" + translatorVersion + "/"},
	Provides: []string{"translator"},
	Sources: []Source{
		OCISource{
			Registry:   "ghcr.io",
			Repository: "go-tex/texmf/translator",
			Reference:  translatorVersion,
			Label:      "ghcr.io/go-tex/texmf/translator:" + translatorVersion,
		},
		HTTPSource{
			URL:   "https://github.com/josephwright/translator/archive/refs/tags/v" + translatorVersion + ".zip",
			Label: "upstream tag josephwright/translator v" + translatorVersion,
		},
	},
}

Translator is the translator package: the word-by-word translation mechanism beamer loads for every talk (beamerbasetranslator.sty requires it and then asks for six dictionaries).

Without it a talk loses the words a theme puts on the slide rather than the author: an undefined \translate leaves its own argument's braces behind, so every theorem and definition was headed "{Theorem} 2." and "{Definition} 1." instead of the language's own word — "Théorème", "Définition" — since the dictionaries carry 21 languages and this corpus is full of talks that are not in English.

The route is the tag's own source archive rather than a release asset: translator publishes no assets, and CTAN's install path (install/macros/latex/contrib/translator.tds.zip) names no version — it serves whatever is current, so it could not carry a pin. The archive's single prefix therefore carries the version, which is what a GitHub source zip unpacks into.

Functions

func UpstreamURL

func UpstreamURL(b Bundle) (string, bool)

UpstreamURL returns the bundle's upstream route, which is by convention its last one: the registry mirrors exist to be tried first, and the publisher needs the source they mirror.

Types

type Bundle

type Bundle struct {
	// Name and Version identify the bundle, and together form its cache
	// directory ("beamer@3.77"). Version is the upstream release's own version,
	// so a cache entry is unambiguous.
	Name    string
	Version string

	// Sources are tried in order until one yields the archive. The first is
	// normally a registry the operator controls; the last should be the upstream
	// release, so the bundle stays reachable when the registry is not.
	Sources []Source

	// SHA256 is the archive's digest, lowercase hex. Every source must produce
	// exactly these bytes — a source is a delivery route, never a trust anchor.
	SHA256 string

	// Prefixes are the archive directories holding the TeX macro files; only
	// entries under one of them are extracted. Everything else in a TDS archive
	// (documentation, sources, PDFs) is thousands of files the engine will never
	// open.
	//
	// More than one is the normal case rather than the exception: a package that
	// serves both plain TeX and LaTeX splits itself between tex/generic/<name>/
	// and tex/latex/<name>/, and needs both halves to load. The files are
	// flattened by base name on the way out, so the split does not survive into
	// the tree the engine sees.
	Prefixes []string

	// Provides are the package names a document may ask for by \usepackage that
	// this bundle answers. A bundle is named for the distribution it comes from,
	// and a document names the .sty file it wants — which is almost never the
	// same word. No document writes \usepackage{pgf} to get TikZ; it writes
	// \usepackage{tikz}, and pgf is the archive that holds tikz.sty.
	//
	// These are the .sty basenames the bundle ships, so the list is a fact about
	// the archive rather than a guess about callers.
	Provides []string
}

A Bundle names one upstream release and says which of its files a TeX engine should be able to see. It is a value, not a fetch: nothing happens until Open.

func Lookup

func Lookup(name string) (Bundle, bool)

Lookup returns the catalogue bundle that answers a name — its own, or one of the package names it Provides. Matching only the bundle name would mean \usepackage{tikz} reaches nothing, since no archive is called tikz.

func WithDependencies added in v0.3.0

func WithDependencies(b Bundle) []Bundle

WithDependencies returns b preceded by everything it requires, in load order and without repeats.

type HTTPSource

type HTTPSource struct {
	// URL is fetched with GET, following redirects.
	URL string
	// Label names the route in messages ("upstream release josephwright/beamer
	// v3.77"). Empty falls back to the URL.
	Label string
	// Client is used when set; nil means a client with a sane timeout.
	Client *http.Client
}

HTTPSource fetches an archive from one URL. It is the plain route: a release asset published by the upstream project, pinned to an exact version so the bytes never move under us.

func (HTTPSource) Describe

func (s HTTPSource) Describe() string

Describe implements Source.

func (HTTPSource) Fetch

func (s HTTPSource) Fetch(ctx context.Context) ([]byte, error)

Fetch implements Source.

type OCISource

type OCISource struct {
	// Registry is the host ("ghcr.io").
	Registry string
	// Repository is the path within it ("go-tex/texmf/beamer").
	Repository string
	// Reference is a tag ("3.77") or a digest ("sha256:…").
	Reference string
	// Label names the route in messages. Empty builds one from the coordinates.
	Label string
	// Client is used when set; nil means a client with a sane timeout.
	Client *http.Client
}

OCISource pulls an archive stored as a single-layer OCI artifact — the shape a registry the operator controls can serve, so the default route to a bundle is theirs rather than a third party's.

It speaks just enough of the distribution API to pull one blob: anonymous token auth, one manifest, one layer. Pulling one artifact does not need a registry client library, and not depending on one keeps this module's dependencies to the standard library.

func (OCISource) Describe

func (s OCISource) Describe() string

Describe implements Source.

func (OCISource) Fetch

func (s OCISource) Fetch(ctx context.Context) ([]byte, error)

Fetch implements Source: resolve the manifest, then pull its single layer.

type Options

type Options struct {
	// CacheDir overrides where extracted bundles live. Empty means
	// <user cache>/go-tex/texmf, which is what the CLI wants.
	CacheDir string

	// Offline forbids every network fetch: a bundle already in the cache is
	// used, and a bundle that is not returns ErrNotCached. This is what an
	// air-gapped or reproducible build sets.
	Offline bool

	// Log, when set, is called with one line per notable step (which source was
	// tried, what it produced). Nil discards them. The CLI wires this to its own
	// stderr so a download is never silent.
	Log func(string)
}

Options configures Open.

type Source

type Source interface {
	// Describe names the route for error messages ("upstream release
	// josephwright/beamer v3.77").
	Describe() string
	// Fetch returns the archive bytes, or an error.
	Fetch(ctx context.Context) ([]byte, error)
}

A Source is one route to a bundle's archive bytes. Fetch must return the complete archive; the digest is checked by the caller, so a Source is never trusted to validate itself.

type Tree

type Tree struct {
	// contains filtered or unexported fields
}

A Tree is an extracted bundle. Resolve answers the engine's file lookups from it, and is safe for concurrent use.

func FromArchive added in v0.2.0

func FromArchive(data []byte, b Bundle) (*Tree, error)

FromArchive builds a Tree from bytes the caller already has, checking them against the bundle's pinned digest. No network, no filesystem.

This is the entry a BROWSER actually needs. Neither ghcr.io nor the GitHub release sends an Access-Control-Allow-Origin header (measured), so a page cannot fetch either of them itself — the bytes have to arrive some other way: same-origin next to the page, a CDN that does send the header, the Cache API, or a bundled asset. Whichever it is, the host does the fetching and this does the verifying, so the digest still decides.

func Open

func Open(ctx context.Context, b Bundle, opt Options) (*Tree, error)

Open makes the bundle available, fetching and extracting it only if the cache does not already hold it. The returned Tree reads from disk lazily.

func OpenInMemory added in v0.2.0

func OpenInMemory(ctx context.Context, b Bundle, opt Options) (*Tree, error)

OpenInMemory makes the bundle available without touching the filesystem: it fetches the archive, checks the digest and keeps the files in memory.

This is what a BROWSER needs. Go's js/wasm filesystem shim answers ENOSYS to most calls, so Open — which extracts into the user cache — cannot run there, even though the very reason Options.Resolve exists is to serve a host with no filesystem. Under node or wasip1, where a real filesystem is bridged in, Open works and is the better choice: it caches.

Nothing is cached here, so every call fetches. A host that compiles more than once should keep the returned Tree, or keep the bytes itself and answer from its own store.

func (*Tree) Dir

func (t *Tree) Dir() string

Dir is where the tree was extracted, or "" for a tree held in memory. Useful for a caller that would rather put it on TEXINPUTS than go through Resolve.

func (*Tree) Names

func (t *Tree) Names() []string

Names lists the base names the tree can answer for, sorted. Mostly for tests and for a caller reporting what it got.

func (*Tree) Resolve

func (t *Tree) Resolve(name string) ([]byte, bool)

Resolve returns the bytes of one file by its base name, which is how a TeX engine asks: "beamerbasetitle.sty", not a path. It matches the signature of the engine's Options.Resolve.

Directories

Path Synopsis
cmd
texmf-pin command
Command texmf-pin prints one catalogue bundle's pin as key=value lines, so that the publish workflow mirrors exactly the version and digest the Go code names — and cannot drift from it.
Command texmf-pin prints one catalogue bundle's pin as key=value lines, so that the publish workflow mirrors exactly the version and digest the Go code names — and cannot drift from it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL