From 76be25e4e7ffac412f8f8ba8bd503640fc5f4441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:41:08 +0100 Subject: [PATCH] feat: add bidirectional linking to Typst code annotations Add cell-id generation and Typst label/link support so that clicking a circled annotation number in code jumps to its description and vice versa. Add upstream-compatible Quarto functions (quarto-circled-number, quarto-annote-color, quarto-code-filename, quarto-code-annotation, quarto-annotation-item) to definitions.typ for forward compatibility with quarto-dev/quarto-cli#14170. --- CHANGELOG.md | 5 +- _extensions/mcanouil/_modules/code-window.lua | 10 +- .../filters/typst-code-annotation.lua | 62 +++++++++---- .../typst/partials/libs/code-annotations.typ | 68 ++++++++++---- .../typst/partials/libs/definitions.typ | 91 +++++++++++++++++++ 5 files changed, 198 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324b9ed..2fb7962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Unreleased -No user-facing changes. +### New Features + +- feat: Add bidirectional linking to Typst code annotations. +- feat: Add upstream-compatible Quarto code annotation functions for forward compatibility with quarto-dev/quarto-cli#14170. ## 0.16.1 (2026-02-21) diff --git a/_extensions/mcanouil/_modules/code-window.lua b/_extensions/mcanouil/_modules/code-window.lua index 5a12ea0..2a3a125 100644 --- a/_extensions/mcanouil/_modules/code-window.lua +++ b/_extensions/mcanouil/_modules/code-window.lua @@ -146,6 +146,7 @@ function M.process_typst(block, config) -- Even without a code-window, wrap with annotated-code() if annotations exist local annot_dict = block.attributes['data-code-annotations'] if annot_dict then + local cell_id = block.attributes['data-cell-id'] or '' local lang = '' if block.classes and #block.classes > 0 then lang = block.classes[1] @@ -154,8 +155,8 @@ function M.process_typst(block, config) local fence_len = math.max(3, max_consecutive_backticks(code_content) + 1) local fence = string.rep('`', fence_len) local typst_code = string.format( - '#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s%s\n%s\n%s]', - annot_dict, fence, lang, code_content, fence + '#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s%s\n%s\n%s]', + annot_dict, cell_id, fence, lang, code_content, fence ) return pandoc.RawBlock('typst', typst_code) end @@ -176,14 +177,15 @@ function M.process_typst(block, config) -- Check for code annotations set by typst-code-annotation filter local annot_dict = block.attributes['data-code-annotations'] + local cell_id = block.attributes['data-cell-id'] or '' local raw_code = string.format('%s%s\n%s\n%s', fence, lang, code_content, fence) -- Wrap with annotated-code() if annotations are present if annot_dict then raw_code = string.format( - '#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode))[%s]', - annot_dict, raw_code + '#annotated-code(%s, mcanouil-colours(mode: effective-brand-mode), cell-id: "%s")[%s]', + annot_dict, cell_id, raw_code ) end diff --git a/_extensions/mcanouil/filters/typst-code-annotation.lua b/_extensions/mcanouil/filters/typst-code-annotation.lua index c40d1b2..af4d248 100644 --- a/_extensions/mcanouil/filters/typst-code-annotation.lua +++ b/_extensions/mcanouil/filters/typst-code-annotation.lua @@ -97,6 +97,16 @@ local LANG_COMMENT_CHARS = { local DEFAULT_COMMENT = { "#" } +--- Counter for generating unique cell IDs across the document. +local cell_id_counter = 0 + +--- Generate a unique cell ID for annotation linking. +--- @return string Unique cell identifier, e.g. "cell-annote-1" +local function next_cell_id() + cell_id_counter = cell_id_counter + 1 + return 'cell-annote-' .. tostring(cell_id_counter) +end + -- ============================================================================ -- ANNOTATION DETECTION -- ============================================================================ @@ -219,14 +229,16 @@ local function annotations_to_typst_dict(annotations) return '(' .. table.concat(parts, ', ') .. ')' end ---- Store annotation data on a CodeBlock as a custom attribute. ---- The code-window module reads this attribute to wrap the code with +--- Store annotation data and cell ID on a CodeBlock as custom attributes. +--- The code-window module reads these attributes to wrap the code with --- annotated-code(). --- @param code_block pandoc.CodeBlock --- @param annotations table +--- @param cell_id string Unique cell identifier for bidirectional linking --- @return pandoc.CodeBlock -local function tag_code_block(code_block, annotations) +local function tag_code_block(code_block, annotations, cell_id) code_block.attributes['data-code-annotations'] = annotations_to_typst_dict(annotations) + code_block.attributes['data-cell-id'] = cell_id return code_block end @@ -234,22 +246,27 @@ end --- Each item renders the circled number inline with the description text. --- @param ol pandoc.OrderedList --- @param annotations table +--- @param cell_id string Unique cell identifier for bidirectional linking --- @return pandoc.Blocks -local function build_annotation_list(ol, annotations) +local function build_annotation_list(ol, annotations, cell_id) local items = pandoc.Blocks({}) for i, item in ipairs(ol.content) do local annotation_number = ol.start + i - 1 if annotations[annotation_number] then local content_inlines = item[1].content or pandoc.Inlines(item[1]) - -- Wrap content in Typst content brackets: #annotation-item(N, [content], colours) + -- Wrap content in Typst content brackets: + -- #annotation-item(N, [content], colours, cell-id: "cell-annote-N") local block_content = pandoc.Inlines({}) block_content:insert(pandoc.RawInline( 'typst', '#annotation-item(' .. tostring(annotation_number) .. ', [' )) block_content:extend(content_inlines) - block_content:insert(pandoc.RawInline('typst', '], ' .. COLOURS_EXPR .. ')')) + block_content:insert(pandoc.RawInline( + 'typst', + '], ' .. COLOURS_EXPR .. ', cell-id: "' .. cell_id .. '")' + )) items:insert(pandoc.Plain(block_content)) end end @@ -265,34 +282,40 @@ end --- @param block pandoc.CodeBlock --- @return pandoc.CodeBlock|nil Cleaned code block, or nil if no annotations --- @return table|nil Annotations table +--- @return string|nil Cell ID for bidirectional linking local function process_code_block(block) if block.attr.classes:includes('cell-code') then - return nil, nil + return nil, nil, nil end local resolved, annotations = resolve_annotations(block) if annotations then - resolved = tag_code_block(resolved, annotations) + local cell_id = next_cell_id() + resolved = tag_code_block(resolved, annotations, cell_id) + return resolved, annotations, cell_id end - return resolved, annotations + return resolved, annotations, nil end --- Process a cell Div, looking for .cell-code CodeBlocks inside. --- @param div pandoc.Div --- @return pandoc.Div|nil Modified div, or nil if no annotations --- @return table|nil Annotations table +--- @return string|nil Cell ID for bidirectional linking local function process_cell_div(div) if not div.attr.classes:includes('cell') then - return nil, nil + return nil, nil, nil end local found_annotations = nil + local found_cell_id = nil local resolved_div = pandoc.walk_block(div, { CodeBlock = function(el) if el.attr.classes:includes('cell-code') then local resolved, annotations = resolve_annotations(el) if annotations and next(annotations) ~= nil then found_annotations = annotations - resolved = tag_code_block(resolved, annotations) + found_cell_id = next_cell_id() + resolved = tag_code_block(resolved, annotations, found_cell_id) return resolved end end @@ -301,9 +324,9 @@ local function process_cell_div(div) }) if found_annotations then - return resolved_div, found_annotations + return resolved_div, found_annotations, found_cell_id end - return nil, nil + return nil, nil, nil end -- ============================================================================ @@ -343,6 +366,7 @@ return { local outputs = pandoc.Blocks({}) local pending_code = nil local pending_annotations = nil + local pending_cell_id = nil local function flush_pending() if pending_code then @@ -350,33 +374,39 @@ return { end pending_code = nil pending_annotations = nil + pending_cell_id = nil end for _, block in ipairs(blocks) do if block.t == 'CodeBlock' then flush_pending() - local resolved, annotations = process_code_block(block) + local resolved, annotations, cell_id = process_code_block(block) if annotations then pending_code = resolved pending_annotations = annotations + pending_cell_id = cell_id else outputs:insert(block) end elseif block.t == 'Div' and block.attr.classes:includes('cell') then flush_pending() - local resolved, annotations = process_cell_div(block) + local resolved, annotations, cell_id = process_cell_div(block) if annotations then pending_code = resolved pending_annotations = annotations + pending_cell_id = cell_id else outputs:insert(block) end elseif block.t == 'OrderedList' and pending_annotations then - local annotation_blocks = build_annotation_list(block, pending_annotations) + local annotation_blocks = build_annotation_list( + block, pending_annotations, pending_cell_id or '' + ) outputs:insert(pending_code) outputs:extend(annotation_blocks) pending_code = nil pending_annotations = nil + pending_cell_id = nil else flush_pending() outputs:insert(block) diff --git a/_extensions/mcanouil/typst/partials/libs/code-annotations.typ b/_extensions/mcanouil/typst/partials/libs/code-annotations.typ index afa7c6b..c0bb02d 100644 --- a/_extensions/mcanouil/typst/partials/libs/code-annotations.typ +++ b/_extensions/mcanouil/typst/partials/libs/code-annotations.typ @@ -11,21 +11,35 @@ /// Render a circled annotation number using brand colours. /// @param n Annotation number to display /// @param colours Colour dictionary (must contain foreground key) +/// @param cell-id Optional cell identifier for bidirectional linking (default: "") /// @return Inline box with circled number -#let circled-number(n, colours) = { - box(baseline: 15%, circle( +#let circled-number(n, colours, cell-id: "") = { + let marker = box(baseline: 15%, circle( radius: 0.55em, stroke: 0.5pt + colours.foreground, )[#set text(size: 0.7em); #align(center + horizon, str(n))]) + marker } /// Wrap a raw code block with annotation markers overlaid at the right edge -/// of specified lines. -/// @param code Raw code block content +/// of specified lines. Supports bidirectional linking when cell-id is provided. /// @param annotations Dictionary mapping annotation numbers to line numbers /// @param colours Colour dictionary +/// @param cell-id Optional cell identifier for bidirectional linking (default: "") +/// @param code Raw code block content /// @return Block with code and overlaid annotation markers -#let annotated-code(annotations, colours, code) = { +#let annotated-code(annotations, colours, cell-id: "", code) = { + // Build a set of first-line positions per annotation number so that + // back-labels are only emitted once (avoiding duplicate labels when + // one annotation spans multiple lines). + let first-lines = (:) + for (num-str, line-num) in annotations { + let key = str(num-str) + if key not in first-lines or line-num < int(first-lines.at(key)) { + first-lines.insert(key, str(line-num)) + } + } + show raw.line: it => { // Check whether this line has an annotation // Keys are strings ("1", "2", ...), values are line numbers (int) @@ -36,12 +50,22 @@ } } if annote-num != none { - // Line with annotation marker right-aligned, preserving natural line spacing - box(width: 100%)[ - #it - #h(1fr) - #circled-number(annote-num, colours) - ] + if cell-id != "" { + let lbl = cell-id + "-annote-" + str(annote-num) + let is-first = first-lines.at(str(annote-num), default: none) == str(it.number) + if is-first { + box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)] #label(lbl + "-back")] + } else { + box(width: 100%)[#it #h(1fr) #link(label(lbl))[#circled-number(annote-num, colours)]] + } + } else { + // No cell-id: simple marker without linking + box(width: 100%)[ + #it + #h(1fr) + #circled-number(annote-num, colours) + ] + } } else { it } @@ -50,14 +74,24 @@ } /// Render a single annotation list item with circled number inline. +/// Supports bidirectional linking when cell-id is provided. /// @param n Annotation number /// @param content Description content /// @param colours Colour dictionary +/// @param cell-id Optional cell identifier for bidirectional linking (default: "") /// @return Block with circled number and description on the same line -#let annotation-item(n, content, colours) = { - block(above: 0.4em, below: 0.4em)[ - #circled-number(n, colours) - #h(0.4em) - #content - ] +#let annotation-item(n, content, colours, cell-id: "") = { + if cell-id != "" { + [#block(above: 0.4em, below: 0.4em)[ + #link(label(cell-id + "-annote-" + str(n) + "-back"))[#circled-number(n, colours)] + #h(0.4em) + #content + ] #label(cell-id + "-annote-" + str(n))] + } else { + block(above: 0.4em, below: 0.4em)[ + #circled-number(n, colours) + #h(0.4em) + #content + ] + } } diff --git a/_extensions/mcanouil/typst/partials/libs/definitions.typ b/_extensions/mcanouil/typst/partials/libs/definitions.typ index 5e05b4b..d610d37 100644 --- a/_extensions/mcanouil/typst/partials/libs/definitions.typ +++ b/_extensions/mcanouil/typst/partials/libs/definitions.typ @@ -34,8 +34,99 @@ // width: 100%, // inset: 8pt, // radius: 2pt, +// stroke: 0.5pt + luma(200), // ) +// ============================================================================ +// Upstream Quarto code annotation and filename functions +// These match the signatures from quarto-dev/quarto-cli PR #14170 so that +// the extension does not break when upstream starts emitting calls to them. +// ============================================================================ + +/// Render a circled annotation number (upstream-compatible signature). +#let quarto-circled-number(n, color: none) = context { + let c = if color != none { color } else { text.fill } + box(baseline: 15%, circle( + radius: 0.55em, + stroke: 0.5pt + c, + )[#set text(size: 0.7em, fill: c); #align(center + horizon, str(n))]) +} + +/// Derive a contrasting annotation colour from a background fill. +/// Light backgrounds get dark circles; dark backgrounds get light circles. +#let quarto-annote-color(bg) = { + if type(bg) == color { + let comps = bg.components(alpha: false) + let lum = if comps.len() == 1 { + comps.at(0) / 100% + } else { + 0.2126 * comps.at(0) / 100% + 0.7152 * comps.at(1) / 100% + 0.0722 * comps.at(2) / 100% + } + if lum < 0.5 { luma(200) } else { luma(60) } + } else { + luma(60) + } +} + +/// Wrap a code block with a filename header tab. +#let quarto-code-filename(filename, body) = { + show raw.where(block: true): it => it + block(width: 100%, radius: 2pt, clip: true, stroke: 0.5pt + luma(200))[ + #set block(spacing: 0pt) + #block(fill: luma(220), width: 100%, inset: (x: 8pt, y: 4pt))[ + #text(size: 0.85em, weight: "bold")[#filename]] + #body + ] +} + +/// Wrap a code block with annotation markers and bidirectional linking. +#let quarto-code-annotation(annotations, cell-id: "", color: luma(60), body) = { + let first-lines = (:) + for (line, num) in annotations { + let key = str(num) + if key not in first-lines or int(line) < int(first-lines.at(key)) { + first-lines.insert(key, str(line)) + } + } + show raw.where(block: true): it => it + show raw.line: it => { + let annote-num = annotations.at(str(it.number), default: none) + if annote-num != none { + if cell-id != "" { + let lbl = cell-id + "-annote-" + str(annote-num) + let is-first = first-lines.at(str(annote-num), default: none) == str(it.number) + if is-first { + box(width: 100%)[#it #h(1fr) #link(label(lbl))[#quarto-circled-number(annote-num, color: color)] #label(lbl + "-back")] + } else { + box(width: 100%)[#it #h(1fr) #link(label(lbl))[#quarto-circled-number(annote-num, color: color)]] + } + } else { + box(width: 100%)[#it #h(1fr) #quarto-circled-number(annote-num, color: color)] + } + } else { + it + } + } + body +} + +/// Render a single annotation list item with optional bidirectional linking. +#let quarto-annotation-item(cell-id, n, content) = { + if cell-id != "" { + [#block(above: 0.4em, below: 0.4em)[ + #link(label(cell-id + "-annote-" + str(n) + "-back"))[#quarto-circled-number(n)] + #h(0.4em) + #content + ] #label(cell-id + "-annote-" + str(n))] + } else { + block(above: 0.4em, below: 0.4em)[ + #quarto-circled-number(n) + #h(0.4em) + #content + ] + } +} + // ============================================================================ // Quarto helper functions // ============================================================================