A pane with the last rendering Claude made or looked at, so the person sees the page Claude judged

A standalone LaTeX package for linguistic examples, with a linguex-compatible input syntax and first-class support for accessible (tagged) PDF output.
linguexx reimplements the familiar dot-syntax of linguex (\ex., \a., \b., \z., \exg., \gll, \glt, \Next, \Last, …) on a fresh expl3 engine, with no dependency on linguex or cgloss4e. It runs on pdfLaTeX, XeLaTeX and LuaLaTeX. A [legacy] option reproduces linguex's exact geometry for drop-in replacement; the default mode is a slightly tighter variant.
Two other input syntaxes are available as options and drive the same engine, so they share one counter, one label system and all layout parameters with the dot syntax: [gb4e] for the exe / xlist environments, and [langsci] for the \ea … \z front-end of langsci-gb4e. Loaded alongside [lazy], either lets a document move over one example at a time. [langsci] provides what langsci-gb4e does — the criterion is what works there, not what its two documents describe — including \eal … \zl, the sub-list numbering variants, the box and font commands and its four package options. It also takes that package's reference convention: under [langsci] a plain \ref prints 1 rather than (1), as langsci-gb4e does, and \ExParenRefs asks for the other one. The two names that have never worked upstream (xlistabr, qlist) are refused by name, with an error saying what to write instead.
When the document enables the LaTeX tagging code, linguexx writes its examples into the PDF structure tree as genuine, accessible objects:
L → LI → Lbl → LBody, sub-levels nested, with valid per-level /ListNumbering (decimal / lower-alpha / lower-roman);/Alt), so a screen reader says "ungrammatical" rather than "asterisk" — see \DeclareJudgment[spoken=…];\GlossTierLang{1}{de} → /Lang);\GlossTransSide) keeps its place in the reading order, grid first;\exannot{[CP]}) carry a spoken form too (\SetAnnotSpoken → /Alt), so a reader hears "complementizer phrase" while the page still shows [CP] (and \ExAnnotFit puts that column where the examples themselves say it belongs, one column per example);\mvto{a}{What} … \mvfrom{a}{\_\_}) are drawn as artifacts, and the base position is read with a note, "__ (moved)" (\SetMoveSpoken sets the word), so the arrow's meaning reaches a reader who cannot see it;\lpzg{sg} → /E "singular"), so they are spoken in full while the page still shows SG, and \lpzglist prints the list of those actually used, as a tagged list;Link elements with their annotation under them: \ref as always, and with it \Next, \Last and the rest of the relative references, which link to the example they name without needing a label — and never to one the document does not have.See doc/TAGGING-NOTES.md for the technical account and doc/PDFUA-CHECKLIST.md for turning a document into a PDF/UA-conformant build.
\documentclass{article}
\usepackage[lazy]{linguexx} % or [legacy], [gb4e], [langsci], combinations
\begin{document}
\ex. A first example.
\a. a sub-example
\b. *a judged sub-example
\exg. Der Hund bellte.\\
the dog barked.\\
\glt `The dog barked.'
\end{document}
For accessible output, add a \DocumentMetadata line before \documentclass; the simplest current form is
\DocumentMetadata{lang=en, tagging=on}
The full manual is linguexx-doc.pdf. Rebuild it with lualatex, not pdflatex: it documents (§9.3) that characters with a diacritic below the letter — Indic and Semitic transliteration, Latvian, Romanian — get a broken text layer under pdflatex, and it contains those characters in the table that explains it. The manual refuses to build with pdflatex for that reason.
Put linguexx.sty where LaTeX can find it — the working directory for a single project, or TEXMF/tex/latex/linguexx/ (then texhash) for a system-wide install.
The regression suite checks example geometry (via pdftotext -bbox) and, for the tagged cases, the PDF structure tree, across all three engines:
cd tests
python3 runtests.py # all cases, all engines
python3 runtests.py -k tagged # one case
python3 runtests.py -v # show every assertion
python3 runtests.py --documents # ... and the manual and the examples too
--documents is the rest of the gate: it builds the manual and the shipped examples and validates examples/ua-demo.pdf with veraPDF, which is what the ua case cannot do for you (it deliberately leaves footnote examples out). It is what CI runs.
It needs the three TeX engines and Python 3, plus poppler-utils (pdftotext for the word boxes every geometric assertion reads, pdfinfo for the structure tree), qpdf (to resolve a named destination to the page it lands on, which no poppler tool reports), show-pdf-tags (the LaTeX team's own tag viewer, the TeX Live package of that name: it resolves /K the way a consumer does, which is what sees an /Alt that wraps nothing) and veraPDF on PATH as verapdf, which needs a JRE — it is the only authoritative oracle for PDF/UA. The suite will not start without poppler or qpdf, and the tagging and PDF/UA cases fail rather than quietly skip when show-pdf-tags or veraPDF is absent. That list is REQUIRED_TOOLS in tests/runtests.py, which is checked against PATH, the suite's own docstring and both CI definitions rather than being kept in step by hand.
expl3 (part of the LaTeX kernel).tikz, which draws the brace of \altn and \altg so that the alternatives are ordinary tagged text rather than a formula, and the movement arrows of \mvfrom/\mvto (with its arrows.meta library), and xspace, for the space after \Last and its family. Both are loaded by the package itself.LaTeX Project Public License 1.3c or later — see LICENSE.
Gerhard Schaden \& Claude (Fable, Opus, Sonnet).
hooks/register.tsx 131 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Shown } from '../types'
5
6// CLAUDE.md has Claude render every PDF and LOOK at it before calling it
7// right. This pane shows the person the same picture: the last PNG that
8// `lxx png` wrote, or that Claude read -- reading an image is how Claude
9// looks at it, so that is the page its verdict was about.
10
11const PANE = 'lxx-render'
12const shown = atom({ plugin: 'lxx-render', key: 'shown' } as const, null)
13
14// `lxx png` prints one line per page: "<path>.png 721x1223px (...)".
15const LXX_PNG = /(?:^|\s)(\/\S+\.png)\s+\d+x\d+px/gm
16
17const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
18
19/** The first bytes of base64 text, decoded by hand: no atob here. */
20function head(base64: string, n: number): number[] {
21 const out: number[] = []
22 let bits = 0
23 let acc = 0
24 for (const ch of base64) {
25 const v = B64.indexOf(ch)
26 if (v < 0) break
27 acc = (acc << 6) | v
28 bits += 6
29 if (bits >= 8) {
30 bits -= 8
31 out.push((acc >> bits) & 0xff)
32 if (out.length === n) break
33 }
34 }
35 return out
36}
37
38/** Width and height from a PNG's IHDR, or undefined if it is not one. */
39export function pngSize(base64: string): { width: number; height: number } | undefined {
40 const b = head(base64, 24)
41 const sig = [0x89, 0x50, 0x4e, 0x47]
42 if (b.length < 24 || sig.some((v, i) => b[i] !== v)) return undefined
43 const u32 = (i: number) => ((b[i]! << 24) | (b[i + 1]! << 16) | (b[i + 2]! << 8) | b[i + 3]!) >>> 0
44 return { width: u32(16), height: u32(20) }
45}
46
47/** The cell box for a picture: as wide as there is room, as tall as the
48 * aspect ratio wants (a cell is about twice as tall as it is wide), and
49 * narrower when that would not fit the rows there are. */
50export function fit(px: { width: number; height: number }, room: { columns: number; rows: number }) {
51 let columns = Math.max(1, Math.min(255, room.columns))
52 let rows = Math.round((columns * px.height) / px.width / 2)
53 if (rows > room.rows) {
54 rows = room.rows
55 columns = Math.max(1, Math.round((rows * 2 * px.width) / px.height))
56 }
57 return { columns, rows: Math.max(1, Math.min(255, rows)) }
58}
59
60async function show($: EngineInterface, path: string, via: Shown['via']): Promise<void> {
61 const at = await $.clock.now()
62 await update($, shown, () => ({ path, via, at }))
63 void $.ui.open({ id: PANE, title: 'Last rendering' })
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.command.register({
69 name: 'lxx-render',
70 description: 'Show the last rendering Claude made or looked at',
71 })
72 return next(e)
73 })
74
75 on('command.run', { command: 'lxx-render' }, async $ => {
76 await $.ui.open({ id: PANE, title: 'Last rendering' })
77 return { text: 'Last rendering pane opened.' }
78 })
79
80 on('tool.call', async ($, e, next) => {
81 const ran = await next(e)
82 if (ran.deny !== undefined || ran.isError === true) return ran
83 if (e.tool === 'Read' && /\.png$/i.test(e.file_path)) {
84 await show($, e.file_path, 'Read')
85 } else if (e.tool === 'Bash' && /\blxx\b[^|;&\n]*\spng\b/.test(e.command)) {
86 const paths = [...(ran.text ?? '').matchAll(LXX_PNG)].map(m => m[1]!)
87 const last = paths.at(-1)
88 if (last !== undefined) await show($, last, 'lxx png')
89 }
90 return ran
91 }).catch(($, e, next) => next(e)) // a picture never blocks a tool
92
93 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94 const { Box, Text, Image, Markdown } = $.ui.resolve(e)
95 const now = await read($, shown)
96 if (now === null) {
97 return (
98 <Box flexDirection="column">
99 <Text dimColor>No rendering yet. One appears here when Claude runs</Text>
100 <Text dimColor>`lxx png` or reads a .png.</Text>
101 </Box>
102 )
103 }
104 const name = now.path.split('/').at(-1) ?? now.path
105 const link = <Markdown text={`[${name}](file://${now.path}) — via ${now.via}`} />
106 let bytes: string
107 try {
108 bytes = (await $.fs.read(now.path, { as: 'bytes' })).base64
109 } catch {
110 return (
111 <Box flexDirection="column">
112 <Text dimColor>{now.path} is gone (a later build replaced it?).</Text>
113 </Box>
114 )
115 }
116 const px = pngSize(bytes)
117 const room = {
118 columns: Math.max(10, (e.viewport?.columns ?? 80) - 2),
119 rows: Math.max(4, (e.viewport?.rows ?? 30) - 4),
120 }
121 const box = px ? fit(px, room) : { columns: room.columns, rows: room.rows }
122 return (
123 <Box flexDirection="column">
124 <Image source={{ png: bytes }} columns={box.columns} rows={box.rows}
125 alt={`${name}: this terminal cannot draw images; open the link below`} />
126 {link}
127 </Box>
128 )
129 })
130}
131types/index.d.ts 15 lines1export type Shown = {
2 /** Absolute path of the PNG. */
3 path: string
4 /** What put it there: `lxx png` or a Read of the image. */
5 via: 'lxx png' | 'Read'
6 /** When, ms since the epoch. */
7 at: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'lxx-render': { shown: Shown | null }
13 }
14}
15