Inspired by https://www.w3.org/TR/annotation-model/ this is the next proposed model:
Overview
This document specifies an annotation system for macOS and XR in which annotations and definitions are independent knowledge objects stored in a shared BibTeX-compatible ledger file. The system serves two user actions:
- Annotate (in Reader): select text in a document being read, write an optional note, specify a category, and view the result in a map.
- Define Concept (in Author): select text in a document being written, name and define the term, specify a category, and view the result in a map.
Both actions produce entries in the same ledger. Both are viewable in the same 2D mapped view on macOS and the same spatial environment in XR.
The selector model (how an entry points to text in a document) is adapted from the W3C Web Annotation Data Model (2017), translated into BibTeX fields. This gives the system robust, well-tested anchoring strategies while maintaining format consistency with the visual-meta ecosystem.
1. The Ledger File
1.1 Location and format
The ledger is a single UTF-8 text file with the extension .bib. On macOS it lives in the shared app group container accessible to both Author and Reader:
~/Library/Group Containers/{group-id}/annotations.bib
The file begins with a required header entry:
@ledger-meta{annotations,
ledger-version = {1},
created = {2026-01-15T09:00:00Z},
last-compacted = {2026-03-01T10:00:00Z}
}
The ledger-version field is an integer. The current version is 1. If the format changes in a future release, the version number increments. Applications must check this field on load: if the version is higher than what the application supports, it must refuse to write (to avoid corruption) and warn the user to update. Reading older versions is always supported.
The remainder of the file contains BibTeX entries, one per knowledge object, separated by blank lines.
1.2 Entry types
The ledger contains three entry types:
| Type | BibTeX key prefix | Created by |
|---|---|---|
@annotation | anno- | Reader (Annotate) |
@definition | def- | Author (Define Concept) |
@category-schema | schema- | System or user |
1.3 Writing to the ledger
All writes are appends. The procedure is:
- Acquire coordination via
NSFileCoordinatorwith aNSFileAccessIntent.writingIntenton the ledger file URL. This is required for cross-process safety between sandboxed macOS apps sharing a Group Container. Do not useflock— it does not coordinate with the file coordination system and will not prevent concurrent writes from the other app. - Within the coordination block, open the file for appending (do not truncate).
- Write the new entry as a BibTeX block followed by a blank line.
- Close the file handle.
NSFileCoordinatorreleases coordination automatically when the block exits.
No existing content is modified in place. To update an entry, append a new entry with the same ID and a newer date value. To delete, append a new entry with the same ID and status = {deleted}.
1.4 Reading from the ledger
On application launch, parse the entire file and build an in-memory index. Parsing proceeds entry by entry. If an individual entry is malformed (unclosed braces, invalid UTF-8, truncated write from a crash), log a warning with the entry’s approximate line number and skip it. A single malformed entry must never prevent the rest of the ledger from loading.
The index is a dictionary keyed by entry ID, where each value is the parsed entry. When multiple entries share the same ID, keep only the one with the latest date. Entries with status = {deleted} are excluded from query results but retained in the index for conflict resolution during import.
Build secondary indexes:
- Document index: dictionary keyed by
target-documentorsource-documentvalue, where each value is a list of entry IDs. Used for “all annotations on this document.” - Category index: dictionary keyed by category string, where each value is a list of entry IDs. Used for “all Issues across corpus.”
- Tag index: an inverted index. Parse the
tagsfield by splitting on,and trimming whitespace from each element. For each individual tag string, maintain a list of entry IDs that contain it. This means the entrytags = {methodology, statistics}produces two index entries:"methodology" → [anno-a3f8c]and"statistics" → [anno-a3f8c]. - Date index: sorted list of (date, entry ID) pairs for time-range queries.
On each write, update the in-memory index incrementally rather than re-parsing the file. Use NSFileCoordinator with a reading intent when re-reading is needed (e.g. if the other app may have written since launch). Register as a NSFilePresenter on the ledger file to receive notifications when the other app modifies it, and re-parse only the new content appended since the last known file size.
1.5 Compaction
Over time the file accumulates superseded entries. Compaction rewrites the file retaining only the latest version of each ID and excluding deleted entries.
Procedure:
- Acquire coordination via
NSFileCoordinatorwith a writing intent. - Build the final state from the in-memory index (all current entries, excluding deleted).
- Write to a temporary file in the same directory.
- Atomically replace the ledger file with the temporary file (
FileManager.replaceItem). - Release coordination.
The compacted file is semantically identical to the original — it produces the same in-memory index.
Git versioning note: if the user versions the ledger with git, compaction produces a large diff (the entire file is rewritten). Recommend: commit the ledger immediately before compaction so the pre-compaction state is preserved, then commit again after compaction. This keeps both the incremental history (in the pre-compaction commits) and the clean state (in the post-compaction commit).
Run compaction on explicit user request, or automatically when the file exceeds a size threshold (e.g. when the file is more than double the size of its compacted equivalent, estimated by counting superseded entries).
2. BibTeX Encoding Rules
All field values in the ledger are enclosed in BibTeX braces {}. The following encoding rules apply to all string fields.
2.1 Special character escaping
BibTeX uses { and } as delimiters and % as a comment character. These must be escaped in field values:
| Character | Escaped form |
|---|---|
{ | \{ |
} | \} |
% | \% |
\ | \\ |
The writer must escape these characters when serializing. The reader must unescape them when parsing. No other characters require escaping.
Example: if the user selects the text f(x) = {y : y > 0}, the selector-exact field is written as:
selector-exact = {f(x) = \{y : y > 0\}}
2.2 Newlines in field values
BibTeX allows field values to span multiple lines within braces. User-written content (the content field) may contain newlines from the text input. These are preserved as literal newlines in the BibTeX file. The parser reads everything between the outermost { and } of the field value, including newlines, as the field content.
Leading whitespace on continuation lines is not significant — it is indentation for readability only. The parser trims leading whitespace from each line after the first, then joins lines with a single space. To preserve an intentional newline in user content, use the sequence \\n (backslash-n). The reader converts \\n to a newline character on parse.
Example:
content = {First paragraph of the note.\\n\\nSecond paragraph
with a continuation line.}
Parses to: First paragraph of the note.\n\nSecond paragraph with a continuation line.
2.3 Comma-separated fields
The fields tags, references, and related-terms contain comma-separated values. The parser splits the field value on , and trims whitespace from each element. Individual values must not contain commas. If a tag, reference key, or term ID contains a comma, it is invalid and the application must reject it at creation time (do not allow commas in tag names or BibTeX keys).
2.4 Integer fields
BibTeX has no integer type. The fields selector-start and selector-end are stored as strings containing decimal digits: selector-start = {2045}. The parser converts these to integers using standard integer parsing. If parsing fails (non-numeric content), the field is treated as absent and the position selector is invalid — fall through to the next selector strategy.
3. Entry Format: @annotation
Created when a user selects text in Reader and invokes Annotate.
@annotation{anno-a3f8c,
target-document = {doc:vm-78b2e4},
selector-type = {TextQuoteSelector},
selector-exact = {the methodology assumes a normal distribution},
selector-prefix = {As outlined in Section 2,},
selector-suffix = {which has been challenged by recent findings},
selector-start = {2045},
selector-end = {2089},
selector-xpath = {/body/section[2]/p[1]},
category = {issue},
category-schema = {scholarly-default},
content = {This contradicts the findings reported in Table 2.\\n\\nThe sample size is too small to assume normality.},
author = {user:frode},
created-by-software = {reader:3.2.1},
date = {2026-03-06T14:23:00Z},
tags = {methodology, statistics},
references = {smith2024-methods}
}
3.1 Field reference: @annotation
Required fields:
| Field | Type | Description |
|---|---|---|
target-document | string | Visual-meta ID of the annotated document. See Section 6 for ID format. |
selector-type | string | Primary selector strategy. One of: TextQuoteSelector, TextPositionSelector. See Section 5. |
selector-exact | string | The exact selected text. Always required regardless of selector-type. Maximum 1000 characters; see Section 3.3. |
category | string | Category from active schema. Stored as literal string. |
author | string | User identifier. |
date | string | ISO 8601 with timezone. 2026-03-06T14:23:00Z |
Required selector fields (depend on selector-type, see Section 5):
| Field | When required | Description |
|---|---|---|
selector-prefix | Required for TextQuoteSelector | Up to 32 characters immediately before the selection. See Section 5.1 for adaptive length. |
selector-suffix | Required for TextQuoteSelector | Up to 32 characters immediately after the selection. |
selector-start | Required for TextPositionSelector | Start offset in Unicode scalars from beginning of document text content. Stored as decimal string. |
selector-end | Required for TextPositionSelector | End offset (exclusive) in Unicode scalars. Stored as decimal string. |
Fallback selector fields (always written if available, regardless of primary type):
| Field | Description |
|---|---|
selector-start | Written as fallback when primary is TextQuoteSelector. |
selector-end | Written as fallback when primary is TextQuoteSelector. |
selector-prefix | Written as fallback when primary is TextPositionSelector. |
selector-suffix | Written as fallback when primary is TextPositionSelector. |
selector-xpath | XPath or synthetic path to the containing structural element. Always written if the document format supports it. |
Optional content fields:
| Field | Description |
|---|---|
content | User’s note. May be absent for a categorised highlight with no written note. Encoded per Section 2.2. |
category-schema | ID of the schema the category was drawn from. Informational only. |
tags | Comma-separated free-form tags. No commas within individual tags. |
references | Comma-separated BibTeX keys. Resolved against target document’s visual-meta first, then project bibliography. |
created-by-software | Application identifier and version. |
status | Only present for deletions: deleted. Absence means active. |
selector-exact-truncated | Present with value true only when the selected text exceeded the 1000-character limit. See Section 3.3. |
3.2 Unique ID generation
IDs are generated as: prefix + first 5 hex characters of SHA-256(author + ISO timestamp + 4 random bytes).
Example: anno-a3f8c, def-c4b2a.
The prefix (anno-, def-, schema-) makes entries visually scannable in the raw ledger. IDs must be unique within the ledger and stable across the entry’s lifetime — they are never regenerated. The combination of author, timestamp (to the second), and 4 random bytes makes collision effectively impossible.
3.3 Maximum selector-exact length
If the user selects more than 1000 characters, selector-exact is truncated to 1000 characters and the field selector-exact-truncated = {true} is added. In this case:
- The TextQuoteSelector resolution algorithm uses the truncated
selector-exactas a prefix match combined withselector-prefixandselector-suffixfor disambiguation. - The TextPositionSelector (
selector-start/selector-end) defines the full extent of the selection and is the authoritative range. - For display in the mapped view node label, use the first 40 characters of
selector-exactregardless of truncation.
4. Entry Format: @definition
Created when a user selects text in Author and invokes Define Concept.
@definition{def-c4b2a,
source-document = {doc:vm-93a1f7},
selector-type = {TextQuoteSelector},
selector-exact = {standoff annotation},
selector-prefix = {concept of},
selector-suffix = {from computational linguistics},
selector-start = {1203},
selector-end = {1223},
selector-xpath = {/body/section[3]/p[2]},
term = {standoff annotation},
category = {concept},
category-schema = {author-default},
content = {An annotation stored separately from the text it annotates, connected only by positional references.},
author = {user:frode},
created-by-software = {author:2.5.0},
date = {2026-03-04T09:15:00Z},
related-terms = {def-a1f3e, def-b8d9c}
}
4.1 Field reference: @definition
Identical to @annotation with the following differences:
| Field | Replaces | Description |
|---|---|---|
source-document | target-document | Visual-meta ID of the document being authored. |
term | (new) | The term being defined. Pre-filled from selected text; user may edit. Required. |
related-terms | (new) | Comma-separated IDs of other @definition entries this term relates to. Optional. |
content | (same name) | The definition text. Required for definitions (unlike annotations where it is optional). |
All selector fields work identically to @annotation. The field names are the same (selector-type, selector-exact, etc.) because selectors describe where text is in a document regardless of context.
4.2 Definitions in Author: selector stability
When the user is writing a document in Author, the document’s text content changes with every edit. Position selectors (selector-start / selector-end) written at definition creation time will become invalid as the author edits the text around them.
The system handles this as follows:
TextQuoteSelectoris always the primary selector for definitions. Theselector-exactfield contains the term text, which the author is unlikely to change (if they do, they are effectively creating a different term).- On document save in Author, the system re-anchors all definitions for that document: for each
@definitionentry in the ledger wheresource-documentmatches, resolve the TextQuoteSelector against the current document text and update the position selector fields (selector-start,selector-end) and XPath field. This is done by appending updated entries (same IDs, new dates) to the ledger. - If a TextQuoteSelector fails to resolve during re-anchoring (the term has been deleted or substantially reworded), mark the definition as unanchored and notify the author.
This re-anchoring on save ensures that position selectors stay current during active editing, while the TextQuoteSelector provides the durable anchor.
5. Selectors
The selector system is adapted from the W3C Web Annotation Data Model. Two primary selector strategies are supported, plus a structural fallback. The system always writes all available selector fields on creation to maximise anchor robustness.
5.1 TextQuoteSelector (preferred primary)
Identifies text by its exact content plus surrounding context.
Fields: selector-exact, selector-prefix, selector-suffix.
Resolution algorithm:
- Extract the document’s full text content as a single string (format-specific, see Section 7).
- Search for
selector-exactin the text (using normalised comparison — see below). - If exactly one match: resolved.
- If multiple matches: disambiguate using
selector-prefixandselector-suffix. For each match, compute a similarity score between the text preceding the match andselector-prefix, and between the text following the match andselector-suffix. Select the match with the highest combined score. - If no match: selector fails. Fall through to next strategy.
Normalised comparison: before matching, normalise both the document text and selector-exact by: collapsing runs of whitespace to single spaces, trimming leading/trailing whitespace. This handles reformatting (e.g. different line breaking) without false negatives.
Adaptive prefix/suffix length: the default prefix/suffix length is 32 characters. At creation time, after writing the initial 32-character prefix/suffix, verify uniqueness: search for selector-exact in the document text. If multiple matches exist and the 32-character prefix/suffix does not disambiguate, extend to 64 characters. If still ambiguous, extend to 128. Cap at 128 characters. This ensures disambiguation while keeping the common case compact.
5.2 TextPositionSelector (fast primary or fallback)
Identifies text by character offset from the start of the document’s text content.
Fields: selector-start, selector-end.
Offset counting: offsets are counted in Unicode scalar values (equivalent to Unicode code points for all characters outside the surrogate pair range). In Swift, use String.unicodeScalars for counting and subscripting. In Python, use len(text) (which counts code points). In JavaScript, use Array.from(text).length. Do not use Swift’s String.count (which counts extended grapheme clusters) or JavaScript’s String.length (which counts UTF-16 code units). The first scalar in the document is at offset 0.
Resolution algorithm:
- Extract the document’s full text content as a single string.
- Index into the string’s Unicode scalars at
selector-starttoselector-end. - Verification: compare the extracted substring against
selector-exact(normalised). If they match, the selector resolves. If they do not match, the selector fails — do not use the position without verification, as it likely points to wrong text after an edit.
5.3 XPathSelector (structural fallback)
Identifies the containing structural element. Never used as primary.
Field: selector-xpath.
Resolution algorithm:
- Evaluate the XPath (or synthetic path — see Section 7) against the document’s structure.
- If the element is found, search within its text content for
selector-exact. - If
selector-exactis found within the element: resolved. - If the element is found but
selector-exactis not within it: partially resolved. Anchor the annotation to the element with a visual indicator that the exact text was not found. - If the element is not found: selector fails.
5.4 Resolution order
When resolving an annotation’s anchor:
- Try the primary selector (identified by
selector-type). - If it fails, try the other selectors as fallbacks:
- If primary was TextQuoteSelector: try TextPositionSelector (with verification against
selector-exact), then XPathSelector. - If primary was TextPositionSelector: try TextQuoteSelector, then XPathSelector.
- If all selectors fail, mark the annotation as unanchored.
5.5 What the system writes on creation
Regardless of which selector is primary, the system writes all available fields:
selector-type = {TextQuoteSelector},
selector-exact = {the selected text here},
selector-prefix = {text before selection},
selector-suffix = {text after selection},
selector-start = {2045},
selector-end = {2089},
selector-xpath = {/body/section[2]/p[1]},
TextQuoteSelector is the default primary for all formats. TextPositionSelector may be primary for plain text files known to be stable. XPathSelector is never primary.
6. Document Identity
6.1 Visual-meta ID format
Every document in the system has a stable identifier stored in its visual-meta. The format is:
doc:vm-{8 hex characters}
Example: doc:vm-78b2e4a1
The 8 hex characters are generated from the first 8 hex characters of SHA-256(author + creation timestamp + 4 random bytes). This ID is assigned once on document creation and never changes across saves, exports, renames, or transfers.
6.2 Where the ID lives
In the document’s visual-meta block (the BibTeX metadata appended to or embedded in the document), the ID is stored as:
@document{doc:vm-78b2e4a1,
title = {My Paper Title},
author = {Frode Hegland},
date = {2026-03-01}
}
The ledger references documents exclusively by this ID, never by filename or path.
6.3 Documents without visual-meta
If Reader opens a document that has no visual-meta (e.g. a PDF downloaded from the web), the system generates a visual-meta ID for it on first annotation. The ID is stored in a local metadata sidecar file:
~/Library/Group Containers/{group-id}/document-ids/{sha256-of-file-first-4kb}.bib
The sidecar contains:
@document-id{doc:vm-f3a8b2c1,
original-filename = {downloaded-paper.pdf},
file-hash = {sha256:first-4kb-hash},
created = {2026-03-06T14:00:00Z}
}
The file hash (SHA-256 of the first 4KB) is used to re-identify the document if it is renamed or moved. This is not cryptographically robust but is sufficient for the purpose of reconnecting a file to its annotations.
7. Text Extraction by Document Format
Selectors operate on a document’s text content: a single string extracted from the document’s native format. The extraction method determines how offsets are counted and what XPath expressions look like.
7.1 Implementation interface
Each application must implement a TextExtractor protocol for each supported format:
protocol TextExtractor {
/// Returns the full text content as a single string.
/// Offsets into this string are in Unicode scalars.
func extractText(from document: Document) -> String
/// Returns the Unicode scalar range for the given XPath, or nil.
func resolveXPath(_ xpath: String, in document: Document) -> Range<Int>?
/// Returns the XPath for the structural element containing the given
/// Unicode scalar offset.
func xpathForOffset(_ offset: Int, in document: Document) -> String
}
The same TextExtractor implementation must be used for both writing and resolving selectors within the same application. If Author and Reader use different extractors for the same format, TextPositionSelector offsets may be inconsistent between them. TextQuoteSelector is immune to this because it operates on content rather than position.
7.2 PDF
Text extraction: PDFDocument → iterate PDFPage objects → page.string for each page. Concatenate all pages with double newline (\n\n) as page separator.
XPath: synthetic path of the form /page[N]/block[M] where N is the 1-based page number and M is the text block index. Fragile and format-specific; serves only as a last-resort fallback.
Primary selector: always TextQuoteSelector. PDF text extraction varies between libraries, making position selectors unreliable across different PDF readers. The TextQuoteSelector’s content-based matching is essential for PDF.
7.3 HTML and EPUB
Text extraction: walk the DOM tree in document order. For each text node, append its content. Insert \n at each block-level element boundary (<p>, <div>, <h1>–<h6>, <blockquote>, <li>, <section>, <article>).
XPath: standard DOM XPath. Example: /html/body/section[2]/p[1]. For EPUB (which is a collection of HTML documents): prefix with the spine item href: {spine-item.href}/html/body/section[2]/p[1].
7.4 DOCX
Text extraction: parse word/document.xml. Walk <w:p> elements in order. For each paragraph, concatenate text from <w:r>/<w:t> elements. Separate paragraphs with \n.
XPath: synthetic path based on paragraph index: /document/body/p[N]. For documents with <w:sectPr> sections: /document/body/section[S]/p[N].
7.5 Plain text and Markdown
Text extraction: the file content is the text content. No transformation.
XPath: synthetic path /p[N], where paragraphs are separated by blank lines (two or more consecutive newlines).
7.6 Author internal format
Author documents are stored as [specify Author’s native format — e.g. DOCX, custom XML, or rich text]. The TextExtractor for Author must produce the same text string from the document being edited as it would from a saved/exported copy of that document. This ensures that selectors written during editing remain valid after save.
If Author uses an internal representation that differs from the export format, the TextExtractor must operate on the internal representation (since that is what the user sees during editing), and selectors must be re-anchored on export if the exported format produces different text content. See Section 4.2 for the re-anchoring procedure.
Note to implementer: this section requires completion once Author’s internal document format is confirmed. The key requirement is: whatever TextExtractor Author uses, it must produce a deterministic string from the current document state, and the same string must be producible from the saved file.
8. Entry Format: @category-schema
@category-schema{scholarly-default,
categories = {important, issue, quote, claim, evidence, method, question},
colors = {blue, red, green, purple, orange, teal, amber},
context = {scholarly-reading},
w3c-motivation-map = {highlighting, questioning, highlighting, assessing, assessing, describing, questioning}
}
8.1 Field reference
| Field | Type | Description |
|---|---|---|
categories | comma-separated strings | Ordered list of category names. |
colors | comma-separated strings | Ordered list of colour names, one per category, matched by position. Fixed vocabulary: red, orange, amber, yellow, green, teal, blue, purple, pink, grey. |
context | string | Human-readable description of when this schema is appropriate. |
w3c-motivation-map | comma-separated strings | Optional. Maps each category to a W3C motivation by position. Used during W3C export. |
8.2 Schema selection
The active schema is a per-project or per-user setting stored in application preferences (not in the ledger). The Annotate and Define Concept dialogs present the active schema’s categories. The user can switch schemas at any time. Existing annotations retain their original category string regardless of schema changes.
8.3 Colour mapping
Applications map named colours to their own platform-specific colour values. The named colour vocabulary is intentionally limited to ensure consistency across macOS and XR rendering.
If an annotation’s category is not present in the active schema (e.g. it was created under a different schema or the schema was modified), render it in grey.
8.4 Default schemas
@category-schema{scholarly-default,
categories = {important, issue, quote, claim, evidence, method, question},
colors = {blue, red, green, purple, orange, teal, amber},
context = {scholarly-reading}
}
@category-schema{author-default,
categories = {person, place, concept, event, method},
colors = {purple, green, blue, orange, teal},
context = {authoring}
}
9. User Interaction
9.1 Annotate (Reader)
Trigger: user selects text, then invokes Annotate via context menu, keyboard shortcut, or toolbar button.
Dialog contents:
- Category picker: horizontal row of coloured buttons, one per category in the active schema, plus an “uncategorised” option (rendered in grey). The first category is pre-selected by default, but the user may select uncategorised for a quick highlight with no classification.
- Note field: multiline text input. Optional — the user may leave it empty.
- Confirm button.
On confirm:
- Capture the selection using the document format’s
TextExtractor:
selector-exact: the selected text (truncated to 1000 characters if longer; setselector-exact-truncated = {true}).selector-prefix: up to 32 characters before selection (extended to 64 or 128 if needed for disambiguation — see Section 5.1).selector-suffix: up to 32 characters after selection (same adaptive extension).selector-start/selector-end: Unicode scalar offsets.selector-xpath: XPath of containing element.
- Escape special characters in all string fields per Section 2.1.
- Generate unique ID:
anno-+ first 5 hex chars of SHA-256(author + ISO timestamp + 4 random bytes). - Construct the
@annotationentry with all fields. - Append to ledger via
NSFileCoordinator(Section 1.3). - Update in-memory index.
- Render the highlight in the document view using the category’s colour.
Time budget: the dialog should appear within 100ms of invocation. The write to disk should complete within 50ms. The user should never wait for the annotation to be saved.
9.2 Define Concept (Author)
Trigger: user selects text, then invokes Define Concept via context menu, keyboard shortcut, or toolbar button.
Dialog contents:
- Term field: pre-filled with selected text. Editable.
- Category picker: same UI as Annotate, using the authoring schema (no uncategorised option — definitions must have a category).
- Definition field: multiline text input. Required.
- Related terms: optional autocomplete field that searches existing
@definitionentries by term name. - Confirm button.
On confirm:
- Same selector capture as Annotate.
- Generate unique ID:
def-+ first 5 hex chars of SHA-256(author + ISO timestamp + 4 random bytes). - Construct the
@definitionentry. - Append to ledger.
- Update in-memory index.
- In the document view, visually mark the term and draw relationship lines to other defined terms that appear in the visible text.
9.3 Editing and deleting
Edit: the user clicks an existing annotation or definition. An edit dialog appears, pre-filled with current content and category. On confirm, a new entry with the same ID, updated content/category, the original selector fields unchanged, and a new date is appended. The in-memory index updates to the latest version.
Delete: the user chooses delete from the edit dialog. A new entry with the same ID, status = {deleted}, and a new date is appended. The in-memory index removes the entry from query results.
10. Views
10.1 In-document view
When a document is opened, query the in-memory index for all entries where target-document (or source-document) matches the document’s visual-meta ID. For each entry, resolve the selector (Section 5.4). Render:
- Annotations: highlight the text range in the category’s colour. If the entry has
content, show a margin indicator. On click/hover, display the note in a popover. - Definitions: render with a distinct style (e.g. dotted underline). On click/hover, display the definition. Draw relationship lines between visible defined terms connected via
related-terms. - Unanchored entries: do not render in the document. Show in a separate “Unanchored” panel with the
selector-exactexcerpt and a “Re-anchor” button that lets the user select new text to attach the entry to.
10.2 Single-document mapped view
Query: all entries referencing the current document.
Layout: a 2D canvas. Each entry is a node:
- Position: initially in document order (vertical) with category column (horizontal).
- Colour: from category schema. Unknown categories render in grey.
- Size: uniform by default; optionally scaled by content length.
- Label: first 40 characters of
selector-exactplus category name. - Edges: draw a line between nodes that share a tag, share a
referencesvalue, or are connected viarelated-terms.
Interactions: filter by category, switch to force-directed layout, click to navigate to passage, drag to rearrange.
10.3 Cross-document mapped view
Query: all entries matching user-specified scope (project, tag, category, date range, or explicit document set).
Layout: force-directed by default. Document origin shown as background grouping or border colour.
Interactions: click to open source document, filter by document/category/tag/date, group by document or category.
10.4 Author-Reader overlay
Available whenever both @definition and @annotation entries exist for the same document, regardless of whether the definitions come from a bundle in the document’s visual-meta or from the local ledger. This means the overlay works for:
- A reader viewing someone else’s document (definitions from bundle, annotations from local ledger).
- An author viewing their own document (definitions and annotations both in local ledger).
Two layers:
- Author layer: definition nodes, edges from
related-terms. - Reader layer: annotation nodes, edges from shared tags and references.
Layers toggle independently, view side by side, or overlay. Cross-layer edges (where an annotation and a definition anchor to overlapping text) are drawn in a distinct style (dashed).
10.5 XR rendering
All four views have XR equivalents:
- 2D canvas → 3D space.
- Nodes → objects (spheres, cards, or panels).
- Edges → visible lines or ribbons.
- Category colours → same colours.
- Layers → spatial depth.
- Click → approach and gaze/point.
- Filter → spatial rearrangement (filtered-out nodes fade or move to periphery).
The data source is identical — the XR environment reads from the same in-memory index.
10.6 XR ledger access
The XR environment may run on a different device (e.g. a headset) that cannot access the macOS filesystem directly. Two access strategies are supported:
Strategy 1: Shared filesystem. If the ledger is in an iCloud-synced location or a shared network volume, the XR application reads the same file. This requires the Group Container to be in an iCloud-accessible location, or the ledger to be symlinked to one.
Strategy 2: Local network service. The macOS application exposes a lightweight read-only HTTP service on the local network (Bonjour-discoverable) that serves the ledger contents as JSON. The XR application discovers the service, fetches the ledger, builds its own in-memory index, and subscribes to change notifications via a simple polling or WebSocket mechanism. The macOS application is the only writer; the XR environment is read-only. Writes initiated in XR are sent as requests to the macOS service, which appends them to the ledger.
The choice between strategies depends on the XR platform. The spec requires at minimum Strategy 1 for initial implementation. Strategy 2 is recommended for production use with standalone headsets.
11. Bundling and Unbundling
11.1 Bundling (export)
When a document is exported, shared, or published:
- Query the ledger for all entries where
target-documentorsource-documentmatches the document’s visual-meta ID. - Filter: include all
@definitionentries (the author’s conceptual structure). Include@annotationentries only if the user explicitly chooses to share them (default: do not share reader annotations). - Serialize the selected entries as BibTeX blocks, applying the escaping rules from Section 2.
- Append to the document’s visual-meta block:
@visual-meta-start{annotations,
bundled-date = {2026-03-09T10:00:00Z},
bundled-by = {reader:3.2.1}
}
% annotation and definition entries here
@visual-meta-end{annotations}
- Bundled entries are snapshots. They are not modified if the ledger changes after bundling.
11.2 Unbundling (import)
When a user opens a document containing bundled entries:
- Parse the annotations section of the visual-meta.
- For each
@definitionentry: display in the Author layer of the overlay view. Do not import into the reader’s ledger unless explicitly requested. - For each
@annotationentry (shared by a previous reader): check the reader’s ledger by ID.
- No match: offer to import. On import, append to ledger.
- Match with same or newer date: skip (ledger is authoritative).
- Match with older date: prompt user to keep or replace.
- Default: prefer the ledger.
12. W3C Compatibility
12.1 Export to W3C format
Each @annotation entry can be serialized as W3C JSON-LD:
{
"@context": "http://www.w3.org/ns/anno.jsonld",
"id": "urn:annotation:anno-a3f8c",
"type": "Annotation",
"motivation": "questioning",
"creator": {
"type": "Person",
"nickname": "frode"
},
"created": "2026-03-06T14:23:00Z",
"generator": {
"type": "Software",
"name": "Reader 3.2.1"
},
"body": {
"type": "TextualBody",
"value": "This contradicts the findings reported in Table 2.",
"format": "text/plain"
},
"target": {
"source": "urn:document:vm-78b2e4",
"selector": [
{
"type": "TextQuoteSelector",
"exact": "the methodology assumes a normal distribution",
"prefix": "As outlined in Section 2,",
"suffix": "which has been challenged by recent findings"
},
{
"type": "TextPositionSelector",
"start": 2045,
"end": 2089
},
{
"type": "XPathSelector",
"value": "/body/section[2]/p[1]"
}
]
}
}
Field mapping:
| BibTeX field | W3C field |
|---|---|
selector-exact | TextQuoteSelector.exact |
selector-prefix | TextQuoteSelector.prefix |
selector-suffix | TextQuoteSelector.suffix |
selector-start | TextPositionSelector.start |
selector-end | TextPositionSelector.end |
selector-xpath | XPathSelector.value |
content | body.value |
category | mapped to motivation via w3c-motivation-map |
author | creator.nickname |
date | created |
created-by-software | generator.name |
target-document | target.source |
12.2 Import from W3C format
Reverse the mapping. If the W3C annotation uses selectors not supported by this system (e.g. SVGSelector, CSSSelector), fall back to any TextQuoteSelector or TextPositionSelector present. If no compatible selector exists, import as unanchored with body content preserved.
13. Data Integrity Rules
These are invariants the system must maintain:
- Every entry has a unique ID. Generated, never user-assigned. Collision is effectively impossible given the generation algorithm.
- Every entry has
selector-exact. Even a highlight with no note captures the selected text. This is the minimum data for re-anchoring. - The ledger is the single source of truth. Bundled entries in documents are snapshots. The ledger wins on conflict.
- Selector fields are immutable on edit. Updating an annotation’s content or category does not change its selectors. To re-anchor, delete and recreate.
- Categories are strings. Schemas are UI suggestions. An entry with an unknown category is valid and rendered in grey.
- Colour is never stored. Derived from category via schema at render time.
- Entries are never modified in place. All changes are appends. The file is safe to read without locking.
- The system never silently discards an annotation. Unanchored entries are surfaced to the user.
- Malformed entries do not block loading. A corrupt entry is skipped with a warning; the rest of the ledger loads normally.
- The ledger file has a version number. Applications check
ledger-versionbefore writing. Newer formats require newer applications.

1 comment