# Accuracy (/docs/accuracy) ## Four ways to find a word on a PDF page [#four-ways-to-find-a-word-on-a-pdf-page] | | Source | Exact where | Drifts where | | ----------------- | ------------------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------ | | **M1 Text items** | `getTextContent()` item transform and width, characters split evenly | item edges | inside multi-word items; poor where an item is a whole line | | **M2 Text layer** | `Range.getClientRects()` on the pdf.js text layer | span edges | inside a span: a substitute font stretched to the span width | | **M3 Glyphs** | the page operator list, walked with pdf.js's text state machine and the embedded fonts' advance widths | every glyph | text it cannot place has no box | | **Drawn** | M3 per text item; an item M3 cannot place fully takes M2, then M1, never mixed inside an item | as M3 | as the fallback, only where used | The glyph walk follows the state pdf.js paints with: the whole state saved by `q`/`Q` and form XObjects, the font from `Tf` or an ExtGState, `Tm`, `Td`, `TD`, `Tc`, `Tw`, `Tz`, `Ts`, `TJ` kerning, `cm`, negative font sizes and vertical fonts. Then it aligns the glyph stream with the text items, resynchronising on each item's origin. ## Measured, not eyeballed [#measured-not-eyeballed] The repository ships a 19-page stress PDF with the true box of every word (76 cases, 5 325 words: narrow and wide glyphs, spacing operators, mixed fonts, ligatures, Hebrew, Arabic, CJK, rotation, `/Rotate`, CropBox, OCR layers, textbook layouts). `bun run test` measures every page in Node and fails when one gets worse. | Across the 19 pages | Drawn (M3 + fallback) | M1 text items | | ------------------------------------- | --------------------- | ------------- | | Median horizontal edge error per page | 0.001 to 0.003 px | 1.5 to 6.6 px | | p90 edge error, max(dx, dy), per page | 1.3 to 4.9 px | 4.8 to 22 px | | Words with a box | 90 to 100 % | | The vertical part of the edge error has a known cause: pdf.js takes the ascent from the embedded font, the ground truth generator took it from reportlab's metrics tables, so every top edge sits about 17 % of the box height higher while the bottom edge matches. That is why the horizontal median is reported on its own. In Node there is no text layer, so the drawn method falls back to M1 there; in the browser it falls back to M2. The benchmark is fail-closed: every method is judged on the same word pairs, a word without a box is a miss (IoU 0), and coverage is reported next to every number. "The glyph walk is best" is a claim about this document and the papers Folio was tested on, not about every PDF; see [limits](/docs/limits). # For humans and agents (/docs/agents) ## For humans [#for-humans] Start with the demo on the [introduction](/docs), then pick your framework: [React](/docs/react) or [Svelte](/docs/svelte). Each has an overview, a PDF page component, selection and marks, and an API page. The [playground](/docs/playground) shows every option live, and [motion](/docs/motion) and [accuracy](/docs/accuracy) explain how it works. Every page has **Copy Markdown** and **Open** buttons at the top if you want to paste a page into a chat. ## For agents [#for-agents] The same docs as plain Markdown, built with the site: | URL | What | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://glyphline.lucaspiera.com/llms.txt) | index of every page with a one-line summary | | [`/llms-full.txt`](https://glyphline.lucaspiera.com/llms-full.txt) | all pages in one file (about 45 KB) | | `/llms.mdx/docs//content.md` | one page, e.g. [`/llms.mdx/docs/react/content.md`](https://glyphline.lucaspiera.com/llms.mdx/docs/react/content.md) | | `node_modules/glyphline/AGENTS.md` | the rules below, shipped inside the npm package | Point your agent at them, for example: ```txt Before using glyphline, read node_modules/glyphline/AGENTS.md and https://glyphline.lucaspiera.com/llms-full.txt ``` In Claude Code or Codex, put that line in your project's `CLAUDE.md` or `AGENTS.md`. ## The rules in AGENTS.md [#the-rules-in-agentsmd] ## What it is [#what-it-is] An `` overlay that draws text highlights over text it does not own: a sentence outline, a word cursor that animates between words, the user's selection and saved marks. Text comes from a `TextSource`: `domSource` (HTML) or `pdfSource` (a pdf.js page). ### Imports (one package, subpaths) [#imports-one-package-subpaths] ```ts import { createHighlighter, wordAtPoint } from 'glyphline'; // core import { domSource } from 'glyphline/dom'; // HTML import { pdfSource } from 'glyphline/pdf'; // pdf.js page; needs pdfjs-dist import { GlyphOverlay, useDomSource, useAsyncSource } from 'glyphline/react'; // React 18/19 import { GlyphOverlay, domText, asyncText, highlight } from 'glyphline/svelte'; // Svelte 5 import 'glyphline/style.css'; // default colours, import once import 'glyphline/pdf.css'; // only if you render the pdf.js text layer without pdf_viewer.css ``` There are no packages named `react-glyphline` or `svelte-glyphline`. Install only `glyphline` (plus `pdfjs-dist` for PDFs). ### Rules that are easy to get wrong [#rules-that-are-easy-to-get-wrong] 1. The overlay must sit inside a positioned element (`position: relative`) that wraps exactly the text. `GlyphOverlay` and the `gl-overlay` class stretch the svg over that parent. 2. Indices (`word`, `sentence`) are into `source.words` / `source.sentences` of that source. After the text changes, use a new source (`useDomSource` / `domText` rebuild it for you). 3. Targets can also be character ranges `{ start, end }` (end exclusive) into `source.text`. 4. Sources read the DOM or pdf.js: create them in the browser only (effects, refs, attachments), never during SSR. The React entry is marked `"use client"`. 5. PDF: call `getDocument({ url, fontExtraProperties: true })`. Without it the glyph positions fall back to a less precise method. Pass `OPS` from `pdfjs-dist` to `pdfSource`. 6. PDF boxes are in page units at scale 1; the svg `viewBox` is the page size. Do not multiply by the zoom yourself: size the page element and the overlay scales. 7. For selections inside a PDF, call `source.withTextLayer(textLayer.textDivs, layerEl, scale)` after rendering pdf.js `TextLayer`, and pass the returned source. 8. To draw the selection: `options={{ trackSelection: { snapToWords: true, onChange } }}`; add class `gl-native-selection-hidden` to the text element to hide the browser's own colour. 9. A white PDF page inside a dark app: `theme="light"` on `GlyphOverlay`. 10. Click to word: `wordAtPoint(source, svgElement, e.clientX, e.clientY)` returns a word index or -1. Skip it when `document.getSelection()?.isCollapsed` is false (the user is selecting). ### Minimal examples [#minimal-examples] React: ```tsx const [ref, source] = useDomSource();
…
; ``` Svelte 5: ```svelte
…
``` No framework: ```ts const hl = createHighlighter(svg, domSource(article), { trackSelection: true }); hl.update({ sentence: 0, word: 0 }); hl.destroy(); // when done ``` # Core API (/docs/api) The core, `glyphline`. The framework packages wrap it: [React API](/docs/react/api), [Svelte API](/docs/svelte/api). ## createHighlighter [#createhighlighter] ```ts function createHighlighter(svg: SVGSVGElement, source: TextSource, options?: HighlighterOptions): Highlighter; ``` Adds its paths to `svg`, sets its `viewBox` from the source and returns: ### HighlightState [#highlightstate] ### HighlighterOptions [#highlighteroptions] ## TextSource [#textsource] Anything that can be highlighted. `domSource` and `pdfSource` return one; you can write your own (a canvas editor, a custom renderer). ```ts interface TextSource { text: string; words: Word[]; // { index, start, end, text, sentence } sentences: Sentence[]; // { index, start, end, text, firstWord, lastWord } boxes(start: number, end: number, overlay: Element): Box[]; // one per line fragment viewBox(overlay: Element): Box; live?: boolean; // re-measure on overlay resize rangeFromSelection?(selection: Selection | null): TextRange | null; } ``` `segment(text, { breakAt, locale })` gives you `words` and `sentences` for your own text. ## domSource [#domsource] ```ts function domSource(root: Element, options?: { locale?: string; skip?: string }): DomTextSource; ``` Adds `root`, `rangeFromSelection(selection)`, `charAtPoint(x, y)` and `toRange(start, end)`. ## pdfSource [#pdfsource] ```ts function pdfSource(page: PDFPageProxy, options: { OPS: Record; locale?: string }): Promise; ``` Adds `page`, `pageText`, `width` and `height` (the page at scale 1), `geometry` (per-character boxes and which method placed each item), `fallbackItems`, `methods` (each method's raw boxes), `withTextLayer(textDivs, container, scale)` and `rangeFromSelection(selection)`. ## Helpers [#helpers] | Export | | | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | `wordAtPoint(source, svg, x, y)` | word index under a viewport point, or -1 | | `toOverlay(svg, x, y)` | viewport point to overlay units | | `wordAt(words, charIndex)` | word index containing a character | | `snapToWords(source, range)` | grow a range to whole words | | `wordRange`, `sentenceRange` | index to `{ start, end }` | | `sentenceShape`, `shapePath`, `outlinePath`, `wordPath` | outline geometry | | `interpolateWord`, `interpolateSentence`, `easeOutCubic` | motion | | `walkGlyphs`, `geometryFromGlyphs`, `geometryFromItems`, `geometryFromTextLayer`, `withFallback` (from `glyphline/pdf`) | the PDF positioning methods on their own | # Getting started (/docs/getting-started) ### Install [#install] ```sh bun add glyphline ``` For PDF pages add `pdfjs-dist`. The React and Svelte adapters are in the same package, `glyphline/react` and `glyphline/svelte`; each has its own section with complete examples: [React](/docs/react), [Svelte](/docs/svelte). ### Put an overlay over the text [#put-an-overlay-over-the-text] The overlay is an `` inside a positioned element that wraps the text. The `gl-overlay` class from `glyphline/style.css` stretches it over its parent and sets the colours. ```html
…
``` For a PDF page, the wrapper is the page element that holds the canvas and the text layer.
### Build a text source [#build-a-text-source] A source is the text, its words and sentences, and a way to measure any character range. In React and Svelte the hooks below (`useDomSource`, `domText`) do this step for you. ```ts import { domSource } from "glyphline/dom"; const source = domSource(document.getElementById("text")!); ``` ```ts import { getDocument, OPS } from "pdfjs-dist"; import { pdfSource } from "glyphline/pdf"; // fontExtraProperties keeps the font metrics the glyph walk reads const doc = await getDocument({ url, fontExtraProperties: true }).promise; const page = await doc.getPage(1); const source = await pdfSource(page, { OPS }); ``` See [PDF pages](/docs/pdf) for rendering the canvas and the text layer. ### Highlight [#highlight] ```tsx import { GlyphOverlay, useDomSource } from "glyphline/react"; import "glyphline/style.css"; const [ref, source] = useDomSource();
…
; ```
```svelte
…
```
```ts import { createHighlighter } from "glyphline"; import "glyphline/style.css"; const hl = createHighlighter(svg, source); hl.update({ sentence: 0, word: 0 }); // indices into source.sentences / source.words hl.update({ word: 1 }); // the cursor glides there ```
A target can also be a character range, `{ start, end }`, for anything that is not a whole word or sentence.
## Next [#next] * Tune the motion and shapes in the [playground](/docs/playground). * Draw the reader's selection and keep it as marks: [Selection and marks](/docs/selection). * Drive the cursor from speech: [Read-aloud](/docs/read-aloud). # HTML text (/docs/html) `domSource(root)` walks the text nodes under `root` and joins them into one string. Inline markup (`em`, `a`, `strong`, spans) is transparent; a block boundary (paragraph, heading, list item) ends a sentence even without punctuation, so a heading never runs into the paragraph after it. ```ts import { domSource } from "glyphline/dom"; const source = domSource(article, { locale: "pl", // default: the nearest lang attribute, then "en" skip: "pre, code, [data-glyphline-skip]", // default skips script, style, textarea… }); ``` In React, `useDomSource` builds the source and rebuilds it when the text changes; in Svelte, `domText` does the same. ## Reflow [#reflow] HTML moves: a resize, a font load, an expanded section. An HTML source is `live`, so the highlighter watches the overlay with a `ResizeObserver` and redraws when its size changes. For changes that do not resize the overlay (a web font swapping in), call `hl.refresh()`. When the **content** changes, build a new source and pass it to `hl.setSource()`. Indices and ranges refer to the text the source was built from. ## Clicking a word [#clicking-a-word] ```ts import { wordAtPoint } from "glyphline"; article.addEventListener("click", (e) => { const word = wordAtPoint(source, svg, e.clientX, e.clientY); if (word >= 0) hl.update({ word }); }); ``` `wordAtPoint` uses the highlighter's own boxes, so it works the same for PDF sources. An HTML source also has `charAtPoint(x, y)` for the caret position under a point. ## Scrolling into view [#scrolling-into-view] ```ts const w = source.words[word]; source.toRange(w.start, w.end)?.startContainer.parentElement?.scrollIntoView({ block: "nearest" }); ``` # Introduction (/docs) The page below is a PDF rendered by pdf.js. glyphline draws the yellow sentence outline and the amber word cursor over it. Press **Read** or click any word. ## What it draws [#what-it-draws] * **A sentence outline.** A range over several lines is one rounded shape, not a stack of rectangles. Line ends of justified text are snapped so the edge stays straight, and a range that crosses columns becomes one shape per column. * **A word cursor.** Along a line it stretches toward the next word and contracts onto it; to another line it travels with the same motion; from nothing it pops in. Interrupting mid-flight continues from what is on screen. * **The selection.** The reader's selection drawn as one shape that follows the glyphs, optionally snapped to whole words. * **Marks.** Static ranges (notes, search hits) with your own classes. Everything is an `` overlay with `pointer-events: none`, so the text underneath stays selectable and clickable. ## Where positions come from [#where-positions-come-from] A highlight is only as good as its boxes. For HTML, glyphline asks the browser (`Range.getClientRects`). For PDF pages it walks the page's glyph stream with the embedded fonts' advance widths, the same way pdf.js paints the canvas, and falls back per text item to the pdf.js text layer where it cannot. The [accuracy page](/docs/accuracy) shows the difference and how it was measured. ## One package [#one-package] ```sh bun add glyphline ``` | Import | What | | ---------------------------------- | -------------------------------------------------------------------- | | `glyphline` | the core: `createHighlighter`, motion, shapes, `wordAtPoint` | | `glyphline/pdf` | text and geometry of a pdf.js page | | `glyphline/dom` | text and geometry of HTML | | [`glyphline/react`](/docs/react) | ``, `useDomSource`, `useAsyncSource`, `useHighlighter` | | [`glyphline/svelte`](/docs/svelte) | ``, `domText`, `asyncText`, `use:highlight` | | `glyphline/style.css` | default colours | The core has no dependencies. `react`, `svelte` and `pdfjs-dist` are optional peers: install only what your app uses. ## Browser support [#browser-support] Any browser with `Intl.Segmenter` and `ResizeObserver`. The automated tests run in Chromium. # Limits (/docs/limits) * **Type3 fonts and text drawn as paths** have no advance widths. Those items fall back to the text layer or the items method; scans without an OCR layer have no text at all. * **Vertical writing** is placed per glyph, but sentence outlines assume horizontal lines: a vertical sentence becomes one shape per column. * **Hyphenated line breaks** in PDFs are joined by a heuristic (letter, hyphen, line end, lowercase letter). A compound word that happens to break at its hyphen becomes two words. * **Sentences** come from `Intl.Segmenter` plus an abbreviation list (e.g., et al., Fig.) and layout breaks in PDFs (font size changes, block gaps, list markers, table cells). An unusual abbreviation can still split a sentence. * **Special tokens** split the way `Intl.Segmenter` splits them: `42 ± 3` is three words, CJK follows the segmenter's dictionary. * **Letter-spaced text** (tracked capitals in a heading) often comes out of pdf.js with a space between every letter, so each letter becomes a word. * **Drop caps** are usually a separate text item, often placed before the heading in the content stream, so the large initial and the rest of its word are two words. * **HTML that changes** needs a new source. Ranges are offsets into the text the source was built from. * **Right-to-left text** is positioned correctly; the cursor's stretch direction follows screen position, not reading order. # Motion (/docs/motion) Each highlight is a tween from what is on screen now to the new target. The interpolators are pure functions you can import and test (`interpolateWord`, `interpolateSentence`). Scrub through each kind of move: ## Interruptions [#interruptions] A new target while a move is running starts a new tween from the current frame, not from the old target. Fast reading never queues moves up; the cursor bends toward the latest word. ## Defaults [#defaults] | | Duration | Easing | | ---------------- | -------- | -------------- | | Word cursor | 240 ms | ease-out cubic | | Sentence outline | 340 ms | ease-out cubic | | Selection | 120 ms | ease-out cubic | At 240 words per minute a word lasts 250 ms, so the cursor lands before the voice moves on. The outline is slower because it travels further and should draw the eye less. ## Reduced motion [#reduced-motion] With `prefers-reduced-motion: reduce` every highlight jumps. `respectReducedMotion: false` overrides it (for demos like this site's playground); `animate: false` turns motion off for everyone. ## Without the highlighter [#without-the-highlighter] The geometry and motion are exported on their own, for a canvas renderer or another framework's tweens: ```ts import { easeOutCubic, interpolateWord, wordPath } from "glyphline"; const tween = interpolateWord(current, { box: nextBox, o: 1 }); // each frame: const f = tween(easeOutCubic(progress)); path.setAttribute("d", f ? wordPath(f.box) : ""); ``` The library started this way: Folio fed the same interpolators to Svelte's `Tween`. # PDF pages (/docs/pdf) glyphline does not render PDFs. You render the page with pdf.js as usual; `pdfSource` reads the same page's text and glyph positions, and the highlighter draws in page units over it. ## Layers [#layers] From the bottom: the canvas, the native text layer (transparent, selectable), the glyphline ``. The svg's `viewBox` is the page size at scale 1, so a zoom is one CSS size change and the highlights never need recomputing. ```html
``` `glyphline/pdf.css` has the minimum CSS the text layer needs to line up with the canvas, so you do not need all of `pdf_viewer.css`. ## Full example [#full-example] ```ts import { getDocument, GlobalWorkerOptions, OPS, TextLayer } from "pdfjs-dist"; import { createHighlighter } from "glyphline"; import { pdfSource } from "glyphline/pdf"; import "glyphline/style.css"; import "glyphline/pdf.css"; GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; const doc = await getDocument({ url: "/paper.pdf", fontExtraProperties: true }).promise; const page = await doc.getPage(1); const scale = 1.5; const viewport = page.getViewport({ scale }); // 1. canvas const canvas = el.querySelector("canvas")!; canvas.width = viewport.width * devicePixelRatio; canvas.height = viewport.height * devicePixelRatio; canvas.style.width = `${viewport.width}px`; const ctx = canvas.getContext("2d")!; ctx.scale(devicePixelRatio, devicePixelRatio); await page.render({ canvas, canvasContext: ctx, viewport }).promise; // 2. text layer (selectable text) const layerEl = el.querySelector(".textLayer")!; const layer = new TextLayer({ textContentSource: await page.getTextContent(), container: layerEl, viewport }); await layer.render(); // 3. glyphline const base = await pdfSource(page, { OPS }); const source = base.withTextLayer(layer.textDivs, layerEl, scale); const hl = createHighlighter(el.querySelector("svg")!, source, { trackSelection: true }); hl.update({ sentence: 0, word: 0 }); ``` `withTextLayer` is optional. It adds the text layer as the first fallback for text items the glyph walk cannot place (still better than splitting an item evenly), and it makes `rangeFromSelection` work, since the selection happens in the text layer. ## `fontExtraProperties` [#fontextraproperties] Pass `fontExtraProperties: true` to `getDocument`. Without it pdf.js drops the font ascent, descent and matrix after loading, the glyph walk has nothing to measure with, and every word falls back to the text items method. Words still get boxes, just less precise ones. ## Many pages [#many-pages] Make one source per page and one overlay per page. Indices are page-local: `source.words[i]` is the i-th word of that page. To read across pages, keep a page number with your cursor and pass `null` to the pages that are not current. ## Zoom and rotation [#zoom-and-rotation] Boxes are in page units at scale 1, after the page's `/Rotate` and CropBox, so the same source serves every zoom. Re-render the canvas and call `layer.update({ viewport })` as pdf.js documents; the overlay scales with its element. # Playground (/docs/playground) Move the cursor with the buttons (or click a word), change the options on the right, and slow it down to see each move. The code under the panel is the options object for what you see. Things to try: * **4× slower** and **word →**: the leading edge reaches the next word first, then the trailing edge catches up. * **next line**: the same stretch, vertically, while the cursor glides along the line. * **other column**: the sentence outline cannot morph between columns, so it fades through nothing. * **ease-out back**: an overshoot reads as playful; a reader usually wants the default. # React API (/docs/react/api) ```ts import { GlyphOverlay, useAsyncSource, useDomSource, useHighlighter } from "glyphline/react"; ``` ## `` [#glyphoverlay] Renders the `` overlay. Put it inside a positioned element that wraps the text. ## `useHighlighter(source, state, options?)` [#usehighlightersource-state-options] The hook behind `GlyphOverlay`. Returns a ref callback for your own ``: ```tsx const ref = useHighlighter(source, { sentence, word }, { animate: true }); return ; ``` ## `useDomSource(options?)` [#usedomsourceeoptions] `[ref, source]` for an element's HTML text. Rebuilt when the text changes (at most once per frame). `options` are those of [`domSource`](/docs/api#domsource), read when the element mounts. ## `useAsyncSource(make, deps)` [#useasyncsourcemake-deps] Runs `make` when `deps` change and returns its result once it resolves, dropping results that arrive after a newer call. Return `null` from `make` for "nothing yet". Meant for `pdfSource`: ```ts const source = useAsyncSource(() => page && pdfSource(page, { OPS }), [page]); ``` Everything else (sources, `wordAtPoint`, shapes, motion) comes from the core: see [API](/docs/api). # React overview (/docs/react) Every demo on this site is built with `glyphline/react`. ## Install [#install] ```sh bun add glyphline # for PDF pages bun add pdfjs-dist ``` The React adapter ships inside `glyphline` as `glyphline/react`; React is an optional peer, so Svelte apps never install it. Import the stylesheet once: ```ts import "glyphline/style.css"; ``` ## HTML in five lines [#html-in-five-lines] ```tsx import { GlyphOverlay, useDomSource } from "glyphline/react"; import "glyphline/style.css"; export function Article({ word, sentence }: { word: number; sentence: number }) { const [ref, source] = useDomSource(); return (

Alice was beginning to get very tired of sitting by her sister on the bank…

); } ``` `useDomSource` returns a ref and the source for that element. The source is rebuilt when the element's text changes (React rendered new children), so word and sentence indices always match what is on screen. It is `null` until the element mounts; `GlyphOverlay` draws nothing until then. ## Clicking a word [#clicking-a-word] ```tsx import { wordAtPoint } from "glyphline";
{ const svg = e.currentTarget.querySelector("svg.gl-overlay"); if (!source || !svg || !document.getSelection()?.isCollapsed) return; const w = wordAtPoint(source, svg, e.clientX, e.clientY); if (w >= 0) setWord(w); }} > ``` The `isCollapsed` check keeps a drag (a text selection) from also moving the cursor. ## Next.js [#nextjs] The package starts with `"use client"`, so `GlyphOverlay` and the hooks work from a server component tree. Sources read the DOM or pdf.js, so they are only made in the browser; both hooks handle that. ## Next [#next] * [PDF pages in React](/docs/react/pdf) * [Selection and marks in React](/docs/react/selection) * [API reference](/docs/react/api) # PDF pages (/docs/react/pdf) The page above is this component, with a timer moving `word`. It renders the canvas and the text layer with pdf.js, and `useAsyncSource` turns the page into a glyphline source. ```tsx "use client"; import type { PDFPageProxy } from "pdfjs-dist"; import { OPS, TextLayer } from "pdfjs-dist"; import { pdfSource, type PdfTextSource } from "glyphline/pdf"; import { GlyphOverlay, useAsyncSource } from "glyphline/react"; import { useEffect, useRef, useState } from "react"; import "glyphline/style.css"; import "glyphline/pdf.css"; export function PdfPage({ page, scale, sentence, word }: { page: PDFPageProxy; // from getDocument({ url, fontExtraProperties: true }) scale: number; sentence: number; word: number; }) { const host = useRef(null); const canvas = useRef(null); const layer = useRef(null); const base = useAsyncSource(() => pdfSource(page, { OPS }), [page]); const [source, setSource] = useState(null); useEffect(() => { if (!base || !canvas.current || !layer.current || !host.current) return; const vp = page.getViewport({ scale }); const c = canvas.current; const dpr = devicePixelRatio; c.width = vp.width * dpr; c.height = vp.height * dpr; const ctx = c.getContext("2d")!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); host.current.style.setProperty("--scale-factor", String(scale)); host.current.style.setProperty("--total-scale-factor", String(scale)); const render = page.render({ canvas: c, canvasContext: ctx, viewport: vp }); let tl: TextLayer | null = null; render.promise.then(async () => { layer.current!.replaceChildren(); tl = new TextLayer({ textContentSource: await page.getTextContent(), container: layer.current!, viewport: vp }); await tl.render(); setSource(base.withTextLayer(tl.textDivs, layer.current!, scale)); }, () => {}); return () => { render.cancel(); tl?.cancel(); }; }, [page, base, scale]); const vp = page.getViewport({ scale }); return (
); } ``` Until the text layer is ready the overlay draws from `base` (glyphs, falling back to text items); after that it uses the text layer as the fallback too, and selections work. `theme="light"` keeps the light colours on the white page in a dark app. The [PDF guide](/docs/pdf) explains the layers, `fontExtraProperties` and zoom. # Selection and marks (/docs/react/selection) ```tsx import type { Mark, TextRange } from "glyphline"; import { GlyphOverlay, useDomSource } from "glyphline/react"; import { useState } from "react"; export function Annotated() { const [ref, source] = useDomSource(); const [selected, setSelected] = useState<{ range: TextRange; text: string } | null>(null); const [marks, setMarks] = useState([]); return ( <>
…
setSelected(range ? { range, text } : null), }, }} />
); } ``` An inline `options` object is fine: the overlay only redraws when shapes or class names change, and `onChange` is read when it fires, so the latest closure is used. Store `start`, `end` and `className` with the document and pass them back as `marks`. [Selection and marks](/docs/selection) covers the details shared with Svelte. # Read-aloud (/docs/read-aloud) The cursor moves when you call `update({ word })`. For text-to-speech, the voice reports where it is through `boundary` events with a `charIndex` into the text you gave it. ```ts import { wordAt } from "glyphline"; function speak(sentenceIndex: number) { const s = source.sentences[sentenceIndex]; if (!s) return; const u = new SpeechSynthesisUtterance(s.text); u.onboundary = (e) => { if (e.name !== "word") return; const word = wordAt(source.words, s.start + e.charIndex); if (word >= 0) hl.update({ sentence: sentenceIndex, word }); }; u.onend = () => speak(sentenceIndex + 1); hl.update({ sentence: sentenceIndex, word: s.firstWord }); speechSynthesis.speak(u); } ``` Speak one sentence per utterance: the outline moves at the right moment, and long utterances do not hit the length limits some engines have. ## Voices without boundaries [#voices-without-boundaries] Many voices report no word boundaries, or only the first. A reader should not freeze then. Keep a watchdog: if the next boundary is overdue (estimate a word's duration from its length and the rate), move the cursor on a timer, and hand control back to the voice when boundaries come again. Show the user which mode is active: a timed cursor is an estimate, not synchronisation. ## Words the voice reads as one [#words-the-voice-reads-as-one] `segment` joins tokens a voice speaks as one word (`p.3`, `e.g.`, `x@y.com`) and keeps math symbols (`±`, `≤`) as words because voices read them. A hyphenated line break in a PDF (`dispro-` / `portionately`) is one word drawn as two boxes. # Selection and marks (/docs/selection) ## Drawing the selection [#drawing-the-selection] ```ts const hl = createHighlighter(svg, source, { trackSelection: { snapToWords: true, onChange: (range, text) => showToolbar(range, text), }, }); ``` glyphline listens to `selectionchange`, converts the selection inside its source to a character range and draws it with the sentence outline. Selections elsewhere on the page are ignored. Hide the browser's own colour on the selectable element with the `gl-native-selection-hidden` class; the selection itself still works, so copy, the context menu and keyboard selection behave as usual. `snapToWords` changes what is drawn and reported, not the browser's selection: copy still copies what the browser selected. Without `trackSelection` you can draw any range yourself: `hl.update({ selection: { start, end } })`. ## Keeping a mark [#keeping-a-mark] A mark is a character range plus an id and an optional class: ```ts const marks = [ { id: "n1", start: 120, end: 168, className: "note-yellow" }, { id: "n2", start: 402, end: 431, className: "note-green" }, ]; hl.update({ marks }); ``` ```css .gl-mark.note-yellow { fill: oklch(0.9 0.15 95 / 0.75); } ``` Each mark path has `data-id`. Store `start` and `end` with the document; they redraw at any zoom or width. For HTML that changes, store some surrounding text too and re-anchor it. ## A toolbar over the selection [#a-toolbar-over-the-selection] `onChange` gives you the range; the selection's position on screen comes from the browser: ```ts const rect = document.getSelection()?.getRangeAt(0).getBoundingClientRect(); ``` Prevent `mousedown` on the toolbar buttons (`e.preventDefault()`) so pressing one does not clear the selection. # Styling (/docs/styling) `glyphline/style.css` is small and every colour is a custom property on `.gl-overlay`: ```css .gl-overlay { --gl-sentence: oklch(0.93 0.09 95 / 0.75); --gl-word: oklch(0.85 0.15 85 / 0.9); --gl-selection: oklch(0.82 0.1 250 / 0.55); --gl-mark: oklch(0.9 0.12 140 / 0.6); --gl-blend: multiply; } ``` The overlay uses `mix-blend-mode: multiply`, so on white paper the text stays fully dark under the colour, the way a highlighter pen looks. ## Dark mode [#dark-mode] Multiply darkens on a dark background, so the stylesheet switches to dimmer colours with `screen` when `` has a `dark` class or `data-theme="dark"`, or when the system is dark and `` is not marked light. A PDF page usually stays white in a dark interface: mark its overlay `data-gl-theme="light"` (`theme="light"` on `GlyphOverlay` in React and Svelte). ## Class names [#class-names] | Element | Class | | -------------------------- | ---------------------------------------- | | the svg | `gl-overlay` | | saved marks (group, paths) | `gl-marks`, `gl-mark` + your `className` | | selection | `gl-selection` | | sentence outline | `gl-sentence` | | word cursor | `gl-word` | Paths are drawn in that order, so the cursor is on top. `classPrefix: "hl"` changes every `gl-` to `hl-`, for apps that already use the names. ## Shapes [#shapes] ```ts createHighlighter(svg, source, { sentenceShape: { pad: 2, radius: 5, snap: 4 }, // overlay units wordShape: { pad: 1.5, radius: 3 }, }); ``` For PDFs the units are page points at scale 1, so the shapes scale with the zoom. For HTML they are CSS pixels. # Svelte API (/docs/svelte/api) ```ts import { asyncText, domText, GlyphOverlay, glyphline, highlight } from "glyphline/svelte"; ``` ## `` [#glyphoverlay] Renders the `` overlay. Put it inside a positioned element that wraps the text. Other attributes are passed to the svg. ## `use:highlight={params}` [#usehighlightparams] Action for an `` you render yourself. `params` are the props above (`source`, `sentence`, `word`, `marks`, `selection`, `options`). Works in Svelte 4 and 5. ## `{@attach glyphline(params)}` [#attach-glyphlineparams] The same as a Svelte 5 attachment. ## `domText(options?)` [#domtextoptions] `{ source, attach }`: put `{@attach text.attach}` on the element; `text.source` is reactive and rebuilt when the element's text changes. `options` are those of [`domSource`](/docs/api#domsource). ## `asyncText(make)` [#asynctextmake] `{ source }`, resolved from `make()` in an effect: it reruns when anything `make` reads changes and drops results that arrive after a newer run. Return `null` for "nothing yet". Call it during component setup. ```ts const text = asyncText(() => page && pdfSource(page, { OPS })); ``` Everything else (sources, `wordAtPoint`, shapes, motion) comes from the core: see [API](/docs/api). # Svelte overview (/docs/svelte) The demo below is a Svelte 5 app (mounted into this React page) using `glyphline/svelte`. ## Install [#install] ```sh bun add glyphline # for PDF pages bun add pdfjs-dist ``` The Svelte adapter ships inside `glyphline` as `glyphline/svelte` (compiled by your Svelte setup, like any Svelte library); Svelte 5 is an optional peer. Import the stylesheet once: ```ts import "glyphline/style.css"; ``` ## HTML [#html] ```svelte

Alice was beginning to get very tired of sitting by her sister on the bank…

``` `domText()` gives you an attachment and a reactive `source`. The source is rebuilt when the element's text changes (an `{#each}` added a paragraph), so indices always match what is on screen. It is `null` until the element mounts. ## Clicking a word [#clicking-a-word] ```svelte
…
``` ## Component or action [#component-or-action] `` renders the svg for you and binds the highlighter (`bind:highlighter`) if you need `refresh()`. If you already have an ``, use the action instead; it also works in Svelte 4: ```svelte ``` ## SvelteKit [#sveltekit] Sources read the DOM or pdf.js, so they only exist in the browser. `domText` attaches on mount, and `asyncText` runs in an effect, so both are safe in components that also render on the server. Import pdf.js dynamically (`await import("pdfjs-dist")`) inside `onMount` or an effect: it touches `window` when it loads. ## Next [#next] * [PDF pages in Svelte](/docs/svelte/pdf) * [Selection and marks in Svelte](/docs/svelte/selection) * [API reference](/docs/svelte/api) # PDF pages (/docs/svelte/pdf) The same page as on the [React side](/docs/react/pdf), as a Svelte 5 component. `asyncText` turns the pdf.js page into a source; once the text layer is rendered, the source also uses it as a fallback and selections work. ```svelte
``` `theme="light"` keeps the light colours on the white page in a dark app. The [PDF guide](/docs/pdf) explains the layers, `fontExtraProperties` and zoom. # Selection and marks (/docs/svelte/selection) The Svelte demo on the [Svelte page](/docs/svelte) does this. Select a few words there and press **Highlight selection**. ```svelte
…
``` Store `start`, `end` and `className` with the document and pass them back as `marks`. [Selection and marks](/docs/selection) covers the details shared with React.