From 8cfdbbd40a9b6db8b8c11030dabe385405d5dfb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:41:13 +0100 Subject: [PATCH 01/14] feat!: draw the Typst window around Quarto's native annotations Quarto now draws code annotations and filenames in Typst. The window replaces Quarto's quarto-code-filename helper and reads per-block settings from a Typst state set before each block. The code-annotations and Skylighting hot-fixes and the hotfix option are removed. Inline code keeps its background through its own module. --- .../_modules/hotfix/code-annotations.lua | 197 ------- .../_modules/hotfix/skylighting-typst-fix.lua | 241 -------- .../code-window/_modules/inline-code.lua | 59 ++ _extensions/code-window/_schema.yml | 29 - _extensions/code-window/code-window.lua | 524 ++++-------------- _extensions/code-window/main.lua | 45 +- tests/fixtures/annotations-explicit.qmd | 18 + tests/fixtures/annotations-idiomatic.qmd | 19 + tests/run.sh | 73 ++- 9 files changed, 264 insertions(+), 941 deletions(-) delete mode 100644 _extensions/code-window/_modules/hotfix/code-annotations.lua delete mode 100644 _extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua create mode 100644 _extensions/code-window/_modules/inline-code.lua create mode 100644 tests/fixtures/annotations-explicit.qmd create mode 100644 tests/fixtures/annotations-idiomatic.qmd diff --git a/_extensions/code-window/_modules/hotfix/code-annotations.lua b/_extensions/code-window/_modules/hotfix/code-annotations.lua deleted file mode 100644 index 2e6f56e..0000000 --- a/_extensions/code-window/_modules/hotfix/code-annotations.lua +++ /dev/null @@ -1,197 +0,0 @@ ---- @module "code-annotations" ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil ---- @brief Code annotation detection, stripping, and Typst rendering helpers. ---- Scans CodeBlock elements for inline annotation markers (e.g. # <1>, // <2>) ---- and provides utilities for converting annotations to Typst output. - --- ============================================================================ --- LANGUAGE COMMENT CHARACTERS --- ============================================================================ - ---- Map of language identifiers to their single-line comment prefix. ---- @type table -local LANG_COMMENT_CHARS = { - r = '#', - python = '#', - lua = '--', - javascript = '//', - typescript = '//', - go = '//', - rust = '//', - bash = '#', - sh = '#', - zsh = '#', - fish = '#', - c = '//', - cpp = '//', - cxx = '//', - cc = '//', - cs = '//', - java = '//', - scala = '//', - kotlin = '//', - swift = '//', - objc = '//', - php = '//', - ruby = '#', - perl = '#', - julia = '#', - haskell = '--', - elm = '--', - clojure = ';', - scheme = ';', - lisp = ';', - racket = ';', - erlang = '%%', - elixir = '#', - fortran = '!', - matlab = '%%', - ada = '--', - sql = '--', - plsql = '--', - tsql = '--', - mysql = '--', - sqlite = '--', - postgresql = '--', - vb = "'", - vbnet = "'", - fsharp = '//', - stata = '//', - yaml = '#', - toml = '#', - make = '#', - cmake = '#', - dockerfile = '#', - powershell = '#', - nix = '#', - zig = '//', - dart = '//', - groovy = '//', - d = '//', - nim = '#', - crystal = '#', - v = '//', - odin = '//', - mojo = '#', -} - --- ============================================================================ --- ANNOTATION RESOLUTION --- ============================================================================ - ---- Escape a string for use in a Lua pattern. ---- @param s string ---- @return string -local function escape_pattern(s) - return s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') -end - ---- Resolve annotations in a CodeBlock element. ---- Scans each line for a trailing annotation marker (e.g. # <1>) using the ---- language's comment prefix. Strips the marker from the code text and returns ---- the cleaned text along with an annotations table. ---- @param block pandoc.CodeBlock ---- @return string cleaned_text The code with annotation markers removed ---- @return table|nil annotations Maps line numbers (int) to annotation numbers (int), or nil if none found -local function resolve_annotations(block) - if not block.classes or #block.classes == 0 then - return block.text, nil - end - - local lang = block.classes[1]:lower() - local comment = LANG_COMMENT_CHARS[lang] or '#' - - local escaped_comment = escape_pattern(comment) - local pattern = '^(.-)%s*' .. escaped_comment .. '%s*<%s*(%d+)%s*>%s*$' - - local annotations = {} - local lines = {} - local found = false - - local line_num = 0 - for line in (block.text .. '\n'):gmatch('([^\n]*)\n') do - line_num = line_num + 1 - local content, annot_num = line:match(pattern) - if annot_num then - found = true - annotations[line_num] = tonumber(annot_num) - table.insert(lines, content) - else - table.insert(lines, line) - end - end - - if not found then - return block.text, nil - end - - return table.concat(lines, '\n'), annotations -end - --- ============================================================================ --- TYPST CONVERSION HELPERS --- ============================================================================ - ---- Convert an annotations table to a Typst dictionary literal. ---- Keys are stringified line numbers, values are annotation numbers. ---- Example output: (1: 2, 3: 1) ---- @param annotations table Line number to annotation number mapping ---- @return string Typst dictionary literal -local function annotations_to_typst_dict(annotations) - local pairs_list = {} - local keys = {} - for k in pairs(annotations) do - table.insert(keys, k) - end - table.sort(keys) - for _, line_num in ipairs(keys) do - table.insert(pairs_list, - string.format('"%d": %d', line_num, annotations[line_num])) - end - return '(' .. table.concat(pairs_list, ', ') .. ')' -end - ---- Check whether a block is an OrderedList that looks like an annotation list. ---- Annotation lists are OrderedLists immediately following a code block, ---- where each item corresponds to an annotation number. ---- @param block pandoc.Block ---- @return boolean -local function is_annotation_ordered_list(block) - return block and block.t == 'OrderedList' -end - ---- Convert an OrderedList to Typst annotation item RawBlocks. ---- Each list item becomes a #code-window-annotation-item(block-id, n)[...] call. ---- @param ol pandoc.OrderedList The ordered list to convert ---- @param wrapper_prefix string Prefix for the Typst function name ---- @param block_id integer Unique block identifier for bidirectional linking ---- @return pandoc.List List of RawBlock elements -local function ordered_list_to_typst_blocks(ol, wrapper_prefix, block_id) - local blocks = {} - local start = ol.listAttributes and ol.listAttributes.start or 1 - for i, item in ipairs(ol.content) do - local annot_num = start + i - 1 - local content_blocks = pandoc.Blocks(item) - local rendered = pandoc.write(pandoc.Pandoc(content_blocks), 'typst') - rendered = rendered:gsub('%s+$', '') - table.insert(blocks, pandoc.RawBlock('typst', string.format( - '#%s-annotation-item(%d, %d)[%s]', - wrapper_prefix, block_id, annot_num, rendered - ))) - end - return blocks -end - --- ============================================================================ --- MODULE EXPORTS --- ============================================================================ - -return { - LANG_COMMENT_CHARS = LANG_COMMENT_CHARS, - resolve_annotations = resolve_annotations, - annotations_to_typst_dict = annotations_to_typst_dict, - is_annotation_ordered_list = is_annotation_ordered_list, - ordered_list_to_typst_blocks = ordered_list_to_typst_blocks, -} diff --git a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua b/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua deleted file mode 100644 index cc62269..0000000 --- a/_extensions/code-window/_modules/hotfix/skylighting-typst-fix.lua +++ /dev/null @@ -1,241 +0,0 @@ ---- @module "skylighting-typst-fix" ---- @license MIT ---- @copyright 2026 Mickaël Canouil ---- @author Mickaël Canouil ---- @brief Typst skylighting styling override and inline code background. ---- This module redefines the Skylighting function to draw code annotation ---- markers, and adds inline code background support for Typst output. ---- Quarto patches the block styling of its own Skylighting function since ---- 1.9.27 (https://github.com/quarto-dev/quarto-cli/issues/14126), but the ---- redefinition replaces that function, so it carries the block styling ---- (width, inset, radius, stroke) and honours its fill parameter itself. - -local _wrapper_prefix = 'code-window' - ---- Set the wrapper prefix for Typst function name generation. ---- Called by main.lua before each handler invocation. ---- @param prefix string The wrapper prefix (e.g. 'code-window' or 'my-window') -local function set_wrapper(prefix) - _wrapper_prefix = prefix -end - ---- Build a Skylighting override that draws annotation markers. ---- It replaces Quarto's patched function, so it sets the block width, inset, ---- radius and stroke itself, and honours its fill parameter. ---- @return string|nil Typst #let Skylighting(...) definition, or nil -local function build_skylighting_override() - local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method - if not hm then return nil end - - local bg = hm['background-color'] - if not bg or type(bg) ~= 'string' then return nil end - - local circled = _wrapper_prefix .. '-circled-number' - return string.format([==[ -// skylighting-typst-fix override -#let Skylighting( - fill: none, - number: false, - start: 1, - sourcelines, -) = { - let bgcolor = if fill != none { fill } else { rgb("%s") } - let has-gutter = start + sourcelines.len() > 999 - - context { - let _page-bg = _cw-page-bg() - let _fg = _cw-fg(_page-bg) - let _border-colour = color.mix((_fg, 15%%), (_page-bg, 85%%)) - let annot-data = _cw-annotations.get() - let blocks = [] - let lnum = start - 1 - let seen-annotes = (:) - - for ln in sourcelines { - lnum = lnum + 1 - if number { - blocks = blocks + box( - width: if has-gutter { 30pt } else { 24pt }, - text([ #lnum ]), - ) - } - - if annot-data != none { - let annot-num = annot-data.annotations.at(str(lnum), default: none) - if annot-num != none { - let lbl-prefix = "cw-" + str(annot-data.block-id) + "-" - if str(annot-num) not in seen-annotes { - seen-annotes.insert(str(annot-num), true) - blocks = blocks + box(width: 100%%)[ - #ln - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - #label(lbl-prefix + "line-" + str(annot-num)) - ] - } else { - blocks = blocks + box(width: 100%%)[ - #ln - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - ] - } - } else { - blocks = blocks + ln - } - } else { - blocks = blocks + ln - } - blocks = blocks + EndLine() - } - - block( - fill: bgcolor, - width: 100%%, - inset: 8pt, - radius: 2pt, - stroke: 0.5pt + _border-colour, - blocks, - ) - } -} -]==], bg, circled, circled) -end - ---- Build a show raw.line rule for annotation support in idiomatic mode. ---- When Skylighting is not available (idiomatic/native Typst highlighting), ---- this rule reads _cw-annotations state and adds circled annotation markers ---- to annotated lines, mirroring what the Skylighting override does. ---- @return string Typst show rule definition -local function build_raw_line_annotation_rule() - local circled = _wrapper_prefix .. '-circled-number' - return string.format([==[ -// idiomatic-mode annotation support for raw blocks -#show raw.line: it => { - context { - let annot-data = _cw-annotations.get() - if annot-data == none { - it - } else { - let annot-num = annot-data.annotations.at(str(it.number), default: none) - if annot-num == none { - it - } else { - let lbl-prefix = "cw-" + str(annot-data.block-id) + "-" - let first-line = calc.min( - ..annot-data.annotations.pairs() - .filter(p => p.at(1) == annot-num) - .map(p => int(p.at(0))) - ) - if it.number == first-line { - box(width: 100%%)[ - #it - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - #label(lbl-prefix + "line-" + str(annot-num)) - ] - } else { - box(width: 100%%)[ - #it - #h(1fr) - #link(label(lbl-prefix + "item-" + str(annot-num)))[ - #%s(annot-num, bg-colour: annot-data.bg-colour) - ] - ] - } - } - } - } -} -]==], circled, circled) -end - ---- Shape of the box that holds inline code, after its fill. -local _inline_box_style = 'inset: (x: 3pt, y: 0pt), outset: (y: 3pt), ' - .. 'radius: 2pt, stroke: none)[' - ---- Process inline Code for Typst format. ---- Puts the Code element in a box that carries the theme background colour. ---- The element itself stays in the document, so Pandoc highlights it and ---- writes the token definitions for it. ---- @param el pandoc.Code Inline code element ---- @return pandoc.Inlines The boxed code -local function process_typst_inline(el) - local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method - local bg = hm and hm['background-color'] - local opening, closing - - if type(bg) == 'string' then - opening = string.format('#box(fill: rgb("%s"), ', bg) .. _inline_box_style - closing = ']' - else - opening = '#context { let _bg = _cw-page-bg(); let _f = _cw-fg(_bg); ' - .. 'box(fill: color.mix((_f, 10%), (_bg, 90%)), ' .. _inline_box_style - closing = '] }' - end - - return pandoc.Inlines({ - pandoc.RawInline('typst', opening), - el, - pandoc.RawInline('typst', closing), - }) -end - ---- Inject Skylighting override at the start of the document. ---- Always injected when code-window is enabled to support annotation markers. -function Pandoc(doc) - if not quarto.doc.is_format('typst') then - return doc - end - - -- Guard: skip if override or annotation rule already injected. - for _, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' - and (blk.text:find('// skylighting-typst-fix override', 1, true) - or blk.text:find('// idiomatic-mode annotation support', 1, true)) then - return doc - end - end - - local override = build_skylighting_override() - local rule = override or build_raw_line_annotation_rule() - - -- Insert after the code-window function definitions so the override/rule can - -- reference _cw-annotations and the wrapper-prefixed circled-number function. - local insert_pos = 1 - for idx, blk in ipairs(doc.blocks) do - if blk.t == 'RawBlock' and blk.format == 'typst' - and blk.text:find('_cw%-annotations') then - insert_pos = idx + 1 - break - end - end - table.insert(doc.blocks, insert_pos, pandoc.RawBlock('typst', rule)) - return doc -end - ---- Walk the document tree and box inline Code with background styling. ---- The extension contributes the pass once, so it sees each Code element ---- once and boxes it once, theorem titles included. Metadata such as the ---- document title is left alone: the template writes it before the ---- definitions the box relies on. -local function process_inline_code(doc) - if not quarto.doc.is_format('typst') then - return doc - end - doc.blocks = doc.blocks:walk({ Code = process_typst_inline }) - return doc -end - -return { - set_wrapper = set_wrapper, - filters = { - { Pandoc = Pandoc }, - { Pandoc = process_inline_code }, - }, -} diff --git a/_extensions/code-window/_modules/inline-code.lua b/_extensions/code-window/_modules/inline-code.lua new file mode 100644 index 0000000..1c85bb4 --- /dev/null +++ b/_extensions/code-window/_modules/inline-code.lua @@ -0,0 +1,59 @@ +--- @module "inline-code" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Background box for inline code in Typst output. +--- @description Quarto gives inline code no background in Typst. This pass puts +--- each inline Code element in a box filled with the theme background, or with +--- a tone mixed from the page when the theme gives none. The mixed tone uses the +--- colour helpers that the code-window definitions declare. + +--- Shape of the box that holds inline code, after its fill. +local _inline_box_style = 'inset: (x: 3pt, y: 0pt), outset: (y: 3pt), ' + .. 'radius: 2pt, stroke: none)[' + +--- Process inline Code for Typst format. +--- Puts the Code element in a box that carries the theme background colour. +--- The element itself stays in the document, so Pandoc highlights it and +--- writes the token definitions for it. +--- @param el pandoc.Code Inline code element +--- @return pandoc.Inlines The boxed code +local function process_typst_inline(el) + local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method + local bg = hm and hm['background-color'] + local opening, closing + + if type(bg) == 'string' then + opening = string.format('#box(fill: rgb("%s"), ', bg) .. _inline_box_style + closing = ']' + else + opening = '#context { let _bg = _cw-page-bg(); let _f = _cw-fg(_bg); ' + .. 'box(fill: color.mix((_f, 10%), (_bg, 90%)), ' .. _inline_box_style + closing = '] }' + end + + return pandoc.Inlines({ + pandoc.RawInline('typst', opening), + el, + pandoc.RawInline('typst', closing), + }) +end + +--- Walk the document tree and box inline Code with background styling. +--- The extension contributes the pass once, so it sees each Code element +--- once and boxes it once, theorem titles included. Metadata such as the +--- document title is left alone: the template writes it before the +--- definitions the box relies on. +local function process_inline_code(doc) + if not quarto.doc.is_format('typst') then + return doc + end + doc.blocks = doc.blocks:walk({ Code = process_typst_inline }) + return doc +end + +return { + filters = { + { Pandoc = process_inline_code }, + }, +} diff --git a/_extensions/code-window/_schema.yml b/_extensions/code-window/_schema.yml index 34003e3..f7c3736 100644 --- a/_extensions/code-window/_schema.yml +++ b/_extensions/code-window/_schema.yml @@ -31,35 +31,6 @@ options: type: boolean default: true description: "Render a small chip next to the filename showing the highlighted-line spec for blocks that set 'code-line-numbers' or 'code-window-lines'." - hotfix: - type: object - description: "Temporary hot-fixes for Typst output. These will be removed when Quarto natively supports the corresponding features (see quarto-dev/quarto-cli#14170)." - additionalProperties: false - properties: - code-annotations: - type: [boolean, object] - default: true - description: "Enable the code-annotations hot-fix for Typst output. Use a boolean or a map with 'enabled' and 'quarto-version' keys." - additionalProperties: false - properties: - enabled: - type: boolean - description: "Explicitly enable or disable this hot-fix." - quarto-version: - type: string - description: "Quarto version at or above which this hot-fix is automatically disabled." - skylighting: - type: [boolean, object] - default: true - description: "Enable the Skylighting hot-fix for Typst output (overrides block styling and adds inline code background). Use a boolean or a map with 'enabled' and 'quarto-version' keys." - additionalProperties: false - properties: - enabled: - type: boolean - description: "Explicitly enable or disable this hot-fix." - quarto-version: - type: string - description: "Quarto version at or above which this hot-fix is automatically disabled." attributes: CodeBlock: diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 90bdc65..0d6fca6 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -19,7 +19,6 @@ local pdoc = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/pando local html_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', '')) local cell_output = require(quarto.utils.resolve_path('_modules/cell-output.lua'):gsub('%.lua$', '')) local language = require(quarto.utils.resolve_path('_modules/language.lua'):gsub('%.lua$', '')) -local code_annotations = nil local checker = nil -- ============================================================================ @@ -32,8 +31,6 @@ local checker = nil --- @field style string Window decoration style ('macos', 'windows', 'default') --- @field cell_output boolean Whether the output of an executed cell is framed --- @field typst_wrapper string Typst wrapper function name ---- @field hotfix_code_annotations boolean Whether to apply the code-annotations hot-fix for Typst ---- @field hotfix_skylighting boolean Whether to apply the Skylighting hot-fix for Typst local VALID_STYLES = { ['default'] = true, ['macos'] = true, ['windows'] = true } @@ -46,9 +43,6 @@ local ATTRIBUTE_PREFIX = 'code-window-' --- could not be read, which is a state this extension renders through rather --- than stopping for. Everywhere else _schema.yml decides these seven, so the --- values below are a fallback and not a second place to change one of them. ---- The hotfix defaults are a different story, and are still kept by hand in ---- HOTFIX_DEFAULTS: they are nested in the schema and read on their own path, ---- so changing one in _schema.yml alone still changes nothing. --- All seven stay listed, because the second case has nothing else to read --- from, and an option added to the schema needs an entry here as well or it --- has no default at all on a format the extension does not act on. @@ -74,15 +68,8 @@ local VALID_COLLAPSE = { ['open'] = 'open', } -local HOTFIX_DEFAULTS = { - ['code-annotations'] = true, - ['skylighting'] = true, -} - local CURRENT_FORMAT = nil local CONFIG = nil -local TYPST_BG_COLOUR = nil -local ANNOTATION_BLOCK_COUNTER = 0 -- ============================================================================ -- CELL OUTPUT @@ -353,97 +340,44 @@ local TYPST_COLOUR_HELPERS = [==[ } ]==] ---- Typst annotation helper functions (state, colour, circled numbers, annotation items). ---- Only injected when at least one hot-fix is active. -local TYPST_ANNOTATION_DEF = [==[ -// code-window: annotation state passed to Skylighting via Typst state -#let _cw-annotations = state("cw-annotations", none) - -// Derive a contrasting annotation colour from a background fill. -// Light backgrounds get dark circles; dark backgrounds get light circles. -// Uses ITU-R BT.709 luminance coefficients, matching quarto-cli PR #14170. -#let code-window-annote-colour(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) - } -} - -#let code-window-circled-number(n, bg-colour: none) = { - let c = if bg-colour != none { code-window-annote-colour(bg-colour) } else { luma(120) } - box(baseline: 20%, circle( - radius: 4.5pt, - stroke: 0.5pt + c, - )[#set text(size: 5.5pt, fill: c); #align(center + horizon, str(n))]) -} - -#let code-window-annotation-item(block-id, n, content) = { - let lbl-prefix = "cw-" + str(block-id) + "-" - context { - let target = label(lbl-prefix + "line-" + str(n)) - let has-target = query(target).len() > 0 - [#block(above: 0.4em, below: 0.4em)[ - #if has-target { - link(target)[#code-window-circled-number(n)] - } else { - code-window-circled-number(n) - } - #h(0.4em) - #content - ] #label(lbl-prefix + "item-" + str(n))] - } -} - -#let code-window-annotated-content(content, annotations: (:), bg-colour: none, block-id: 0) = { - if annotations.len() > 0 { - _cw-annotations.update((annotations: annotations, bg-colour: bg-colour, block-id: block-id)) - content - _cw-annotations.update(none) +--- Typst window state and the replacement for Quarto's filename helper. +--- Quarto draws a block with a filename through quarto-code-filename. The +--- filter keeps Quarto's own helper under another name, then replaces it with +--- one that reads the window state the filter sets before each block. A block +--- that refuses the window, or one the filter never visited, goes to Quarto's +--- helper. "code-window" is renamed with the wrapper option. +local TYPST_FILENAME_HELPER = [==[ +#let _cw-window = state("cw-window", none) + +#let _cw-native-filename = quarto-code-filename + +#let quarto-code-filename(filename, body) = context { + let s = _cw-window.get() + if s == none or not s.enabled { + _cw-native-filename(filename, body) } else { - content + code-window( + body, + filename: filename, + is-auto: s.is-auto, + style: s.style, + lines-label: s.lines-label, + ) } } ]==] ---- Typst code-window body content template. ---- Uses annotation wrapper when hot-fixes are active, plain content otherwise. -local TYPST_CONTENT_WITH_ANNOTATIONS = [==[ - code-window-annotated-content( - content, - annotations: annotations, - bg-colour: bg-colour, - block-id: block-id, - ) -]==] - -local TYPST_CONTENT_PLAIN = [==[ - content -]==] - ---- Build the complete Typst code-window function definition. ---- @param has_hotfixes boolean Whether at least one hot-fix is active +--- Build the complete Typst code-window function definition: the colour +--- helpers, the window function, and the replacement for Quarto's filename +--- helper, in that order. --- @return string Typst function definition(s) -local function build_typst_function_def(has_hotfixes) - local content_block = has_hotfixes - and TYPST_CONTENT_WITH_ANNOTATIONS - or TYPST_CONTENT_PLAIN - +local function build_typst_function_def() local fn_def = string.format([==[ #let code-window( content, filename: none, is-auto: false, style: "macos", - annotations: (:), - bg-colour: none, - block-id: 0, lines-label: none, ) = { context { @@ -570,40 +504,21 @@ local function build_typst_function_def(has_hotfixes) above: 0pt, below: 0pt, ) -%s } + content + } }, ) } } -]==], content_block) +]==]) - local result = TYPST_COLOUR_HELPERS - if has_hotfixes then - result = result .. TYPST_ANNOTATION_DEF - end - return result .. fn_def + return TYPST_COLOUR_HELPERS .. fn_def .. TYPST_FILENAME_HELPER end -- ============================================================================ -- TYPST PROCESSING -- ============================================================================ ---- Get the next unique block ID for annotation linking. ---- @return integer -local function next_block_id() - ANNOTATION_BLOCK_COUNTER = ANNOTATION_BLOCK_COUNTER + 1 - return ANNOTATION_BLOCK_COUNTER -end - ---- Build the Typst bg-colour parameter string. ---- @return string Empty string or ', bg-colour: rgb("...")' -local function typst_bg_colour_param() - if not TYPST_BG_COLOUR then - return '' - end - return string.format(', bg-colour: rgb("%s")', TYPST_BG_COLOUR) -end - --- Escape a string for embedding in a Typst double-quoted string literal. --- @param s string --- @return string @@ -611,52 +526,29 @@ local function typst_escape_string(s) return (s:gsub('\\', '\\\\'):gsub('"', '\\"')) end ---- Build a code-window opening RawBlock for Typst. ---- @param filename string ---- @param is_auto boolean ---- @param style string ---- @param annotations table|nil ---- @param block_id integer ---- @param lines_label string|nil Highlighted-lines spec to display in the title bar ---- @return pandoc.RawBlock -local function typst_code_window_open(filename, is_auto, style, annotations, block_id, lines_label) - local annot_param = '' - if annotations and next(annotations) then - annot_param = string.format(', annotations: %s, block-id: %d', - code_annotations.annotations_to_typst_dict(annotations), block_id) - end +--- @class WindowState +--- @field enabled boolean Whether the block gets the window +--- @field is_auto boolean Whether the name was derived rather than written +--- @field style string Window decoration style +--- @field lines_label string|nil Highlighted-lines spec for the title bar - local lines_param = '' - if lines_label and lines_label ~= '' then - lines_param = string.format(', lines-label: "%s"', typst_escape_string(lines_label)) +--- Build the raw Typst block that sets the window state for the block after +--- it. Every block the filter visits sets every field, so a block never +--- inherits a value from the block before it. +--- @param params WindowState +--- @return pandoc.RawBlock +local function typst_window_state(params) + local lines_label = 'none' + if params.lines_label and params.lines_label ~= '' then + lines_label = '"' .. typst_escape_string(params.lines_label) .. '"' end - return pandoc.RawBlock('typst', string.format( - '#%s(filename: "%s", is-auto: %s, style: "%s"%s%s%s)[', - CONFIG.typst_wrapper, - typst_escape_string(filename), - is_auto and 'true' or 'false', - style, - annot_param, - typst_bg_colour_param(), - lines_param - )) -end - ---- Build a standalone annotation wrapper for non-windowed blocks. ---- @param annotations table ---- @param block_id integer ---- @return pandoc.RawBlock opening, pandoc.RawBlock closing -local function typst_annotation_wrapper(annotations, block_id) - local open = pandoc.RawBlock('typst', string.format( - '#%s-annotated-content(annotations: %s, block-id: %d%s)[', - CONFIG.typst_wrapper, - code_annotations.annotations_to_typst_dict(annotations), - block_id, - typst_bg_colour_param() + '#_cw-window.update((enabled: %s, is-auto: %s, style: "%s", lines-label: %s))', + tostring(params.enabled), + tostring(params.is_auto), + params.style, + lines_label )) - local close = pandoc.RawBlock('typst', ']') - return open, close end -- ============================================================================ @@ -871,53 +763,6 @@ function Meta(meta) local global_collapse = resolve_collapse(opts['collapse']) - -- Read code-annotations metadata (Quarto standard option). - local annot_meta = meta['code-annotations'] - local annot_value = annot_meta and pandoc.utils.stringify(annot_meta) or '' - local annotations_enabled = annot_value ~= 'none' and annot_value ~= 'false' - - -- Read hotfix sub-table from extensions.code-window.hotfix. - local ext_config = meta_mod.get_extension_config(meta, EXTENSION_NAME) - local hotfix_meta = ext_config and ext_config['hotfix'] or nil - - -- Deprecation check for old flat skylighting-fix key. - if ext_config and ext_config['skylighting-fix'] ~= nil then - log.log_warning(EXTENSION_NAME, - '"skylighting-fix" is deprecated. Use "hotfix: { skylighting: true/false }" instead.') - end - - -- Parse hotfix options with per-hotfix version-based auto-disable. - -- Each hotfix value can be: - -- boolean/string: true/false to enable/disable - -- map: { enabled: true/false, quarto-version: "x.y.z" } - local hotfix = {} - for key, default in pairs(HOTFIX_DEFAULTS) do - local entry = hotfix_meta and hotfix_meta[key] - if entry ~= nil and pandoc.utils.type(entry) == 'table' then - -- Map form: { enabled: bool, quarto-version: "x.y.z" } - local enabled = true - if entry['enabled'] ~= nil then - enabled = pandoc.utils.stringify(entry['enabled']) == 'true' - end - local ver = entry['quarto-version'] - if ver then - ver = pandoc.utils.stringify(ver) - if ver ~= '' then - local ok, threshold = pcall(pandoc.types.Version, ver) - if ok and quarto.version >= threshold then - enabled = false - end - end - end - hotfix[key] = enabled - elseif entry ~= nil then - -- Simple boolean/string form - hotfix[key] = pandoc.utils.stringify(entry) == 'true' - else - hotfix[key] = default - end - end - CONFIG = { enabled = opts['enabled'] == 'true', auto_filename = opts['auto-filename'] == 'true', @@ -926,22 +771,8 @@ function Meta(meta) typst_wrapper = opts['wrapper'], collapse = global_collapse, lines_label = opts['lines-label'] == 'true', - hotfix_code_annotations = hotfix['code-annotations'], - hotfix_skylighting = hotfix['skylighting'], - code_annotations = annotations_enabled, } - -- Cache syntax highlighting background colour for Typst contrast-aware annotations. - if CURRENT_FORMAT == 'typst' then - local hm = PANDOC_WRITER_OPTIONS and PANDOC_WRITER_OPTIONS.highlight_method - if hm then - local bg = hm['background-color'] - if bg and type(bg) == 'string' then - TYPST_BG_COLOUR = bg - end - end - end - if CURRENT_FORMAT == 'html' and CONFIG.enabled then html_mod.ensure_html_dependency({ name = EXTENSION_NAME, @@ -1060,92 +891,6 @@ local function resolve_window_params(block, resolved, auto_label) return filename, is_auto, overrides.style, overrides.lines_label end ---- Process a single CodeBlock for Typst, returning replacement blocks. ---- Handles both code-window wrapping and standalone annotation rendering. ---- @param block pandoc.CodeBlock ---- @param next_block pandoc.Block|nil The block following this CodeBlock ---- @return pandoc.List replacement_blocks Blocks to splice in ---- @return boolean consumed_next Whether the next block was consumed ---- @return integer|nil annotation_block_id Block ID if annotations were found (for parent propagation) -local function process_typst_block(block, next_block) - if is_quarto_block(block) then - return { block }, false, nil - end - - -- Check this block's own attributes against the CodeBlock group of the - -- schema before anything below reads or rewrites them, in either branch. - local resolved, auto_label = resolve_and_take(block) - - -- The output of an executed cell keeps the shape Quarto gave it, annotations - -- included, unless the engine gave it a filename of its own. - if is_unnamed_cell_output(block) then - return { block }, false, nil - end - - local filename, is_auto, block_style, lines_label = - resolve_window_params(block, resolved, auto_label) - local has_window = filename and filename ~= '' - local effective_style = block_style or CONFIG.style - - -- Resolve annotations if enabled and the code-annotations hot-fix is active. - local annotations = nil - local should_handle_annotations = CONFIG.code_annotations and CONFIG.hotfix_code_annotations - - if should_handle_annotations then - local cleaned_text - cleaned_text, annotations = code_annotations.resolve_annotations(block) - if annotations then - block.text = cleaned_text - end - end - - -- Strip filename attribute so the CodeBlock renders as plain code inside the - -- code-window wrapper (the DecoratedCodeBlock Div is already unwrapped above). - if has_window and block.attributes['filename'] then - block.attributes['filename'] = nil - end - - local has_annotations = annotations and next(annotations) - local consumed_next = false - local result = {} - local block_id = has_annotations and next_block_id() or 0 - - if has_window and has_annotations then - table.insert(result, typst_code_window_open( - filename, is_auto, effective_style, annotations, block_id, lines_label)) - table.insert(result, block) - table.insert(result, pandoc.RawBlock('typst', ']')) - elseif has_window then - table.insert(result, typst_code_window_open( - filename, is_auto, effective_style, nil, 0, lines_label)) - table.insert(result, block) - table.insert(result, pandoc.RawBlock('typst', ']')) - elseif has_annotations then - local open, close = typst_annotation_wrapper(annotations, block_id) - table.insert(result, open) - table.insert(result, block) - table.insert(result, close) - else - table.insert(result, block) - end - - -- Consume the following OrderedList if it is an annotation list. - if has_annotations - and next_block - and code_annotations.is_annotation_ordered_list(next_block) then - local wrapper_prefix = CONFIG.typst_wrapper - local annot_blocks = code_annotations.ordered_list_to_typst_blocks( - next_block, wrapper_prefix, block_id) - for _, ab in ipairs(annot_blocks) do - table.insert(result, ab) - end - consumed_next = true - end - - local returned_block_id = has_annotations and (not consumed_next) and block_id or nil - return result, consumed_next, returned_block_id -end - --- Check if a Div is Quarto's DecoratedCodeBlock wrapper. --- @param div pandoc.Div --- @return boolean @@ -1185,125 +930,73 @@ local function get_decorated_codeblock_filename(div) return nil end ---- Process a flat list of blocks for Typst, handling CodeBlocks and their ---- following OrderedLists. Called recursively on Div contents. +--- Build the window state for a block that Quarto already wrapped in its +--- filename node, from the attributes of the code block inside it. +--- @param div pandoc.Div A DecoratedCodeBlock Div +--- @return pandoc.RawBlock|nil State block, or nil when the node holds no code block for the filter +local function decorated_window_state(div) + local block = extract_codeblock(div) + if not block or is_quarto_block(block) then + return nil + end + local resolved = resolve_and_take(block) + local overrides = read_window_overrides(block, resolved) + return typst_window_state({ + enabled = overrides.enabled, + is_auto = false, + style = overrides.style or CONFIG.style, + lines_label = overrides.lines_label, + }) +end + +--- Walk a list of blocks for Typst and set the window state before each block +--- that Quarto draws through its filename helper. The state block goes before +--- Quarto's node, at the same level, and nothing goes between a block and the +--- block after it, so Quarto still pairs a code block with its annotation list. +--- Inside a layout, the state and the node share one Div, so the layout keeps +--- one cell for them. --- @param blocks pandoc.Blocks|pandoc.List ---- @return pandoc.Blocks processed_blocks ---- @return integer|nil pending_annotation_block_id Block ID if the last block had annotations (for parent consumption) -local function process_typst_blocks(blocks, wrap_codeblocks) - local new_blocks = {} - local pending_annot_block_id = nil - local i = 1 - while i <= #blocks do - local blk = blocks[i] - - if blk.t == 'CodeBlock' then - local next_blk = blocks[i + 1] - local replacement, consumed_next, annot_id = process_typst_block(blk, next_blk) - local to_insert = (wrap_codeblocks and #replacement > 1) - and { pandoc.Div(replacement) } or replacement - for _, rb in ipairs(to_insert) do - table.insert(new_blocks, rb) - end - if consumed_next then - pending_annot_block_id = nil - i = i + 2 +--- @param in_layout boolean|nil Whether the blocks are the cells of a layout +--- @return pandoc.Blocks +local function frame_typst_blocks(blocks, in_layout) + local result = pandoc.Blocks({}) + for _, blk in ipairs(blocks) do + if blk.t == 'Div' and is_decorated_codeblock(blk) then + local state = decorated_window_state(blk) + if state and in_layout then + result:insert(pandoc.Div({ state, blk })) else - pending_annot_block_id = annot_id - i = i + 1 - end - elseif blk.t == 'Div' and is_decorated_codeblock(blk) then - -- Unwrap Quarto's DecoratedCodeBlock to prevent double filename wrapping. - -- Process the inner CodeBlock directly, replacing the entire Div. - local inner_block = extract_codeblock(blk) - if inner_block then - -- Quarto stores the #| filename: value in its custom node registry, not as - -- a plain Pandoc attribute on the inner CodeBlock. Transfer it here so - -- resolve_window_params sees the explicit filename instead of falling back - -- to auto-filename. - if str.is_empty(inner_block.attributes['filename']) then - local fn = get_decorated_codeblock_filename(blk) - if fn then - inner_block.attributes['filename'] = fn - end + if state then + result:insert(state) end - local next_blk = blocks[i + 1] - local replacement, consumed_next, annot_id = process_typst_block(inner_block, next_blk) - local to_insert = (wrap_codeblocks and #replacement > 1) - and { pandoc.Div(replacement) } or replacement - for _, rb in ipairs(to_insert) do - table.insert(new_blocks, rb) - end - if consumed_next then - pending_annot_block_id = nil - i = i + 2 - else - pending_annot_block_id = annot_id - i = i + 1 - end - else - -- Fallback: keep the Div as-is if no CodeBlock found. - local processed, inner_pending = process_typst_blocks(blk.content) - blk.content = processed - table.insert(new_blocks, blk) - pending_annot_block_id = inner_pending - i = i + 1 + result:insert(blk) end elseif blk.t == 'Div' then local is_layout = blk.attributes['layout-ncol'] or blk.attributes['layout-nrow'] or blk.attributes['layout'] - local processed, inner_pending = process_typst_blocks(blk.content, is_layout) - blk.content = processed - table.insert(new_blocks, blk) - -- If the Div's last processed block had pending annotations, - -- check if the next sibling is an OrderedList to consume. - if inner_pending then - local next_blk = blocks[i + 1] - if next_blk and code_annotations.is_annotation_ordered_list(next_blk) then - local annot_blocks = code_annotations.ordered_list_to_typst_blocks( - next_blk, CONFIG.typst_wrapper, inner_pending) - for _, ab in ipairs(annot_blocks) do - table.insert(new_blocks, ab) - end - pending_annot_block_id = nil - i = i + 2 - else - pending_annot_block_id = inner_pending - i = i + 1 - end - else - pending_annot_block_id = nil - i = i + 1 - end + blk.content = frame_typst_blocks(blk.content, is_layout) + result:insert(blk) + elseif blk.t == 'BlockQuote' then + blk.content = frame_typst_blocks(blk.content) + result:insert(blk) elseif blk.t == 'BulletList' or blk.t == 'OrderedList' then for j, item in ipairs(blk.content) do - blk.content[j] = process_typst_blocks(pandoc.Blocks(item)) + blk.content[j] = frame_typst_blocks(item) end - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 - elseif blk.t == 'BlockQuote' then - blk.content = process_typst_blocks(blk.content) - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 + result:insert(blk) elseif blk.t == 'DefinitionList' then - for j, def_item in ipairs(blk.content) do + for _, def_item in ipairs(blk.content) do for k, body in ipairs(def_item[2]) do - def_item[2][k] = process_typst_blocks(pandoc.Blocks(body)) + def_item[2][k] = frame_typst_blocks(body) end end - table.insert(new_blocks, blk) - pending_annot_block_id = nil - i = i + 1 + result:insert(blk) else - pending_annot_block_id = nil - table.insert(new_blocks, blk) - i = i + 1 + result:insert(blk) end end - return pandoc.Blocks(new_blocks), pending_annot_block_id + return result end --- Walk HTML document blocks and transfer the filename from any DecoratedCodeBlock @@ -1347,8 +1040,8 @@ function Pandoc(doc) return doc end - -- Process code blocks and annotations throughout the document tree. - doc.blocks = process_typst_blocks(doc.blocks) + -- Set the window state before every block Quarto draws with a filename. + doc.blocks = frame_typst_blocks(doc.blocks) -- Guard: check if the function definition is already present. local fn_pattern = '#let ' .. CONFIG.typst_wrapper @@ -1359,14 +1052,9 @@ function Pandoc(doc) end end - local has_hotfixes = CONFIG.hotfix_code_annotations or CONFIG.hotfix_skylighting - local fn_def = build_typst_function_def(has_hotfixes) + local fn_def = build_typst_function_def() if CONFIG.typst_wrapper ~= 'code-window' then - fn_def = fn_def:gsub('code%-window%-annote%-colour', CONFIG.typst_wrapper .. '-annote-colour') - fn_def = fn_def:gsub('code%-window%-circled%-number', CONFIG.typst_wrapper .. '-circled-number') - fn_def = fn_def:gsub('code%-window%-annotation%-item', CONFIG.typst_wrapper .. '-annotation-item') - fn_def = fn_def:gsub('code%-window%-annotated%-content', CONFIG.typst_wrapper .. '-annotated-content') - fn_def = fn_def:gsub('#let code%-window%(', '#let ' .. CONFIG.typst_wrapper .. '(') + fn_def = fn_def:gsub('code%-window%(', CONFIG.typst_wrapper .. '(') end table.insert(doc.blocks, 1, pandoc.RawBlock('typst', fn_def)) @@ -1377,13 +1065,6 @@ end -- MODULE EXPORTS -- ============================================================================ ---- Inject the code-annotations module dependency. ---- Called by main.lua before any filter handlers run. ---- @param mod table The code-annotations module -local function set_code_annotations(mod) - code_annotations = mod -end - --- Inject the schema checker. --- Called by main.lua before any filter handlers run. --- @param mod table The checker built from the vendored validator @@ -1392,7 +1073,6 @@ local function set_checker(mod) end return { - set_code_annotations = set_code_annotations, set_checker = set_checker, Meta = Meta, Pandoc = Pandoc, diff --git a/_extensions/code-window/main.lua b/_extensions/code-window/main.lua index 2f28d44..ed10d01 100644 --- a/_extensions/code-window/main.lua +++ b/_extensions/code-window/main.lua @@ -6,7 +6,6 @@ --- Loads all submodules, wires dependencies, and assembles the filter list. local EXTENSION_NAME = 'code-window' -local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) local schema = require(quarto.utils.resolve_path('_vendor/quarto-wizard/schema.lua'):gsub('%.lua$', '')) local check = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/schema-check.lua'):gsub('%.lua$', '')) @@ -28,13 +27,12 @@ local cell_output = require( local language = require( quarto.utils.resolve_path('_modules/language.lua'):gsub('%.lua$', '')) -local code_annotations = require( - quarto.utils.resolve_path('_modules/hotfix/code-annotations.lua'):gsub('%.lua$', '')) +local inline_code = require( + quarto.utils.resolve_path('_modules/inline-code.lua'):gsub('%.lua$', '')) local code_window = require( quarto.utils.resolve_path('code-window.lua'):gsub('%.lua$', '')) -code_window.set_code_annotations(code_annotations) code_window.set_checker(checker) -- ============================================================================ @@ -103,28 +101,6 @@ local function normalise_language(block) return language.CodeBlock(block) end --- ============================================================================ --- SKYLIGHTING HOT-FIX --- ============================================================================ - ---- Load optional skylighting hot-fix module from sibling file. ---- @return table Module table with .filters and .set_wrapper, or empty table -local function load_skylighting_hotfix_module() - local ok, result = pcall(require, - quarto.utils.resolve_path('_modules/hotfix/skylighting-typst-fix.lua'):gsub('%.lua$', '')) - if not ok then - log.log_warning(EXTENSION_NAME, - 'Failed to load optional skylighting hot-fix: ' .. tostring(result)) - return {} - end - if type(result) ~= 'table' then - log.log_warning(EXTENSION_NAME, - 'Skylighting hot-fix did not return a module table.') - return {} - end - return result -end - -- ============================================================================ -- FILTER ASSEMBLY -- ============================================================================ @@ -139,26 +115,15 @@ local filters = { { CodeBlock = code_window.CodeBlock }, } -local skylighting_mod = load_skylighting_hotfix_module() - --- The hot-fix exists only to serve the chrome, so it asks draws_chrome like --- every other pass that does. Its Skylighting override calls the colour helpers, --- and code_window.Pandoc is what declares them, so asking hotfix_skylighting --- alone left the override in a document that declared none. -for _, subfilter in ipairs(skylighting_mod.filters or {}) do +-- The inline-code box uses the colour helpers that code_window.Pandoc +-- declares, so it runs only where that pass draws chrome. +for _, subfilter in ipairs(inline_code.filters) do local wrapped = {} for element_type, handler in pairs(subfilter) do wrapped[element_type] = function(...) if not code_window.draws_chrome() then return nil end - local cfg = code_window.CONFIG() - if not cfg.hotfix_skylighting then - return nil - end - if skylighting_mod.set_wrapper then - skylighting_mod.set_wrapper(cfg.typst_wrapper) - end return handler(...) end end diff --git a/tests/fixtures/annotations-explicit.qmd b/tests/fixtures/annotations-explicit.qmd new file mode 100644 index 0000000..9a8ee90 --- /dev/null +++ b/tests/fixtures/annotations-explicit.qmd @@ -0,0 +1,18 @@ +--- +title: "Annotations on a block with a filename" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +Quarto draws the annotations, and the window frames the block. + +```{.python filename="a.py"} +x = 1 # <1> +y = 2 # <2> +``` + +1. The first value. +2. The second value. diff --git a/tests/fixtures/annotations-idiomatic.qmd b/tests/fixtures/annotations-idiomatic.qmd new file mode 100644 index 0000000..5d2d785 --- /dev/null +++ b/tests/fixtures/annotations-idiomatic.qmd @@ -0,0 +1,19 @@ +--- +title: "Annotations with idiomatic highlighting" +filters: + - code-window +format: + typst: + syntax-highlighting: idiomatic + keep-typ: true +--- + +Quarto draws the annotations, and the window frames the block. + +```{.python filename="a.py"} +x = 1 # <1> +y = 2 # <2> +``` + +1. The first value. +2. The second value. diff --git a/tests/run.sh b/tests/run.sh index 91dd4ae..995575f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -53,12 +53,22 @@ render() { fi } -# Count the code-window calls in a rendered Typst file. The wrapper's own -# definition reads "#let code-window(", so a call is matched at the start of a -# line and with its first argument. +# Count the framed blocks in a rendered Typst file. The filter sets the window +# state on the line before each block it frames, and a framed block is the one +# whose state says the window is on. # $1 rendered file count_typst_windows() { - grep -c '^#code-window(filename' "$1" || true + grep -c '^#_cw-window.update((enabled: true' "$1" || true +} + +# Succeed when the first line that matches one pattern comes before the first +# line that matches another. +# $1 rendered file. $2 earlier pattern. $3 later pattern. +comes_before() { + local first second + first="$(grep -n -m 1 -e "$2" "$1" | cut -d: -f1)" + second="$(grep -n -m 1 -e "$3" "$1" | cut -d: -f1)" + [ -n "${first}" ] && [ -n "${second}" ] && [ "${first}" -lt "${second}" ] } # Succeed when one page of page-break-title.txt holds both strings. pdftotext @@ -124,7 +134,7 @@ else "a box(fill: rgb( call in inline-code-only.typ" fi -# A code block goes through the Skylighting override, and the inline code +# A code block goes through Quarto's Skylighting function, and the inline code # keeps its own box in the same document. render inline-code-with-block typst if grep -q '^#Skylighting(' "${work_dir}/inline-code-with-block.typ" && @@ -298,11 +308,11 @@ done # disagree with nothing to say so. The wrapper name is the option under test # because it reaches the Typst output word for word. render wrapper-default typst -if grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: the schema default names the wrapper" else report fail "wrapper-default: the schema default names the wrapper" \ - "a #code-window( call in wrapper-default.typ" + "a #let code-window( definition in wrapper-default.typ" fi sed -i.aside 's/default: "code-window"/default: "my-window"/' \ @@ -313,12 +323,12 @@ mv "${work_dir}/_extensions/code-window/_schema.yml.aside" \ # The old name has to be gone as well as the new one present, because a rename # that missed a call site would leave both in the document. -if grep -q '^#my-window(' "${work_dir}/wrapper-default.typ" && - ! grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let my-window(' "${work_dir}/wrapper-default.typ" && + ! grep -q 'code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: a default changed in the schema alone is followed" else report fail "wrapper-default: a default changed in the schema alone is followed" \ - "a #my-window( call and no #code-window( call in wrapper-default.typ" + "a #let my-window( definition and no code-window( in wrapper-default.typ" fi # With no schema to read, the fallback in the Lua answers instead, and the @@ -327,11 +337,11 @@ mv "${work_dir}/_extensions/code-window/_schema.yml" "${work_dir}/schema.yml.asi render wrapper-default typst mv "${work_dir}/schema.yml.aside" "${work_dir}/_extensions/code-window/_schema.yml" -if grep -q '^#code-window(' "${work_dir}/wrapper-default.typ"; then +if grep -q '^#let code-window(' "${work_dir}/wrapper-default.typ"; then report pass "wrapper-default: the fallback answers when the schema cannot be read" else report fail "wrapper-default: the fallback answers when the schema cannot be read" \ - "a #code-window( call in wrapper-default.typ" + "a #let code-window( definition in wrapper-default.typ" fi # ============================================================================ @@ -561,6 +571,45 @@ else "one window, for the r block, and no quarto-title-block in index.typ" fi +# ============================================================================ +# Quarto draws the annotations inside the window +# ============================================================================ + +# Quarto takes the annotation comments off the block and draws the circles +# itself. The filter replaces Quarto's filename helper with the window, and +# sets the window state on the line before the block, outside Quarto's node. +render annotations-explicit typst +explicit_typ="${work_dir}/annotations-explicit.typ" +if grep -q '^#let quarto-code-filename(' "${explicit_typ}" && + grep -q '^#quarto-code-filename("a.py")\[' "${explicit_typ}" && + comes_before "${explicit_typ}" \ + '^#_cw-window.update((enabled: true, is-auto: false, style: "macos"' \ + '^#quarto-code-filename("a.py")\['; then + report pass "annotations-explicit: the window replaces Quarto's filename helper" +else + report fail "annotations-explicit: the window replaces Quarto's filename helper" \ + "a #let quarto-code-filename( definition, and the window state before #quarto-code-filename(\"a.py\")[" +fi + +if grep -q '#Skylighting(cell-id:' "${explicit_typ}" && + ! grep -q 'code-window-circled-number' "${explicit_typ}" && + ! grep -q 'skylighting-typst-fix override' "${explicit_typ}"; then + report pass "annotations-explicit: Quarto draws the circles" +else + report fail "annotations-explicit: Quarto draws the circles" \ + "a #Skylighting(cell-id: call and no circled-number helper of the filter" +fi + +# With idiomatic highlighting, Quarto writes a native block and wraps it in +# its own annotation helper, inside the window. +render annotations-idiomatic typst +if grep -q '#quarto-code-annotation(' "${work_dir}/annotations-idiomatic.typ"; then + report pass "annotations-idiomatic: Quarto draws the circles on a native block" +else + report fail "annotations-idiomatic: Quarto draws the circles on a native block" \ + "a #quarto-code-annotation( call in annotations-idiomatic.typ" +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From b445d27b143996662ae3aa10f7b8fb938c54bf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:45:53 +0100 Subject: [PATCH 02/14] feat: frame Typst blocks with a derived name through Quarto's filename node A block named after its language or its label gets Quarto's filename node from the filter, so Quarto still pairs it with its annotation list. In an executed cell, the window state goes before the cell, where Quarto expects the code first. --- _extensions/code-window/code-window.lua | 49 +++++++++++++++++- tests/fixtures/annotations-derived.qmd | 66 +++++++++++++++++++++++++ tests/run.sh | 57 +++++++++++++++++++-- 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/annotations-derived.qmd diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 0d6fca6..fac2a34 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -949,6 +949,38 @@ local function decorated_window_state(div) }) end +--- Frame a code block that has no filename of its own. A block with a derived +--- name is put in Quarto's filename node, which Quarto built for the blocks +--- with a filename before this filter ran, and the window state goes before it. +--- Every other block is returned as it is, its own attributes taken off. +--- @param block pandoc.CodeBlock +--- @return pandoc.Block[] Blocks to put in place of the code block +local function frame_typst_codeblock(block) + if is_quarto_block(block) then + return { block } + end + -- Check this block's own attributes against the CodeBlock group of the + -- schema before anything below reads them. + local resolved, auto_label = resolve_and_take(block) + if is_unnamed_cell_output(block) then + return { block } + end + local filename, is_auto, block_style, lines_label = + resolve_window_params(block, resolved, auto_label) + if not is_auto or str.is_empty(filename) then + return { block } + end + return { + typst_window_state({ + enabled = true, + is_auto = true, + style = block_style or CONFIG.style, + lines_label = lines_label, + }), + quarto.DecoratedCodeBlock({ filename = filename, code_block = block }), + } +end + --- Walk a list of blocks for Typst and set the window state before each block --- that Quarto draws through its filename helper. The state block goes before --- Quarto's node, at the same level, and nothing goes between a block and the @@ -961,7 +993,14 @@ end local function frame_typst_blocks(blocks, in_layout) local result = pandoc.Blocks({}) for _, blk in ipairs(blocks) do - if blk.t == 'Div' and is_decorated_codeblock(blk) then + if blk.t == 'CodeBlock' then + local framed = frame_typst_codeblock(blk) + if in_layout and #framed > 1 then + result:insert(pandoc.Div(framed)) + else + result:extend(framed) + end + elseif blk.t == 'Div' and is_decorated_codeblock(blk) then local state = decorated_window_state(blk) if state and in_layout then result:insert(pandoc.Div({ state, blk })) @@ -976,6 +1015,14 @@ local function frame_typst_blocks(blocks, in_layout) or blk.attributes['layout-nrow'] or blk.attributes['layout'] blk.content = frame_typst_blocks(blk.content, is_layout) + -- Quarto reads the code of an executed cell as the first block of the + -- cell, so the window state of that code goes before the cell instead. + local first = blk.content[1] + if blk.classes:includes('cell') and first + and first.t == 'RawBlock' and first.text:find('#_cw-window.update', 1, true) then + result:insert(first) + blk.content:remove(1) + end result:insert(blk) elseif blk.t == 'BlockQuote' then blk.content = frame_typst_blocks(blk.content) diff --git a/tests/fixtures/annotations-derived.qmd b/tests/fixtures/annotations-derived.qmd new file mode 100644 index 0000000..a537a40 --- /dev/null +++ b/tests/fixtures/annotations-derived.qmd @@ -0,0 +1,66 @@ +--- +title: "Annotations on blocks with a derived name" +filters: + - code-window +format: + typst: + keep-typ: true +extensions: + code-window: + style: macos +--- + +The block below is named after its language. + +```{.python} +x = 1 # <1> +``` + +1. A value. + +The block below names its own label. + +```{.bash code-window-auto-label="Terminal"} +echo labelled +``` + +The first block below sets its own style, and the second block does not. + +```{.python code-window-style="windows"} +styled = True +``` + +```{.python} +plain = True +``` + +The block below highlights one line. + +```{.python code-line-numbers="2"} +first = 1 +second = 2 +``` + +::: {.callout-note} + +## A callout + +```{.python} +a = 1 # <1> +``` + +1. A value in a callout. + +::: + +The div below has the shape an engine gives an executed cell. + +::: {.cell} + +```{.python .cell-code} +b = 1 # <1> +``` + +::: + +1. A value in a cell. diff --git a/tests/run.sh b/tests/run.sh index 995575f..8911483 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -492,12 +492,12 @@ fi render auto-label-forced typst forced_typst="${work_dir}/auto-label-forced.typ" if [ "$(count_typst_windows "${forced_typst}")" -eq 3 ] && - grep -q '^#code-window(filename: "Terminal", is-auto: true' "${forced_typst}" && - grep -q '^#code-window(filename: "run.sh", is-auto: false' "${forced_typst}"; then + grep -q '^#quarto-code-filename("Terminal")\[' "${forced_typst}" && + grep -q '^#quarto-code-filename("run.sh")\[' "${forced_typst}"; then report pass "auto-label-forced: only the labelled block takes the label in Typst" else report fail "auto-label-forced: only the labelled block takes the label in Typst" \ - "three code-window calls, with one for Terminal with is-auto: true and one for run.sh" + "three windows, with one for Terminal and one for run.sh" fi # The language pass runs for a labelled block and keeps its label, or the @@ -546,7 +546,7 @@ fi render language-not-first typst not_first_typ="${work_dir}/language-not-first.typ" if [ "$(count_typst_windows "${not_first_typ}")" -eq 4 ] && - [ "$(grep -c '^#code-window(filename: "bash"' "${not_first_typ}" || true)" -eq 2 ]; then + [ "$(grep -c '^#quarto-code-filename("bash")\[' "${not_first_typ}" || true)" -eq 2 ]; then report pass "language-not-first: the Typst windows are named after the language" else report fail "language-not-first: the Typst windows are named after the language" \ @@ -610,6 +610,55 @@ else "a #quarto-code-annotation( call in annotations-idiomatic.typ" fi +# ============================================================================ +# A block with a derived name goes through Quarto's filename node +# ============================================================================ + +# The filter builds Quarto's filename node for a block named after its +# language or its label, so Quarto still pairs the block with its annotation +# list, in a callout and in an executed cell as well. +render annotations-derived typst +derived_typ="${work_dir}/annotations-derived.typ" +if grep -q '^#quarto-code-filename("python")\[' "${derived_typ}" && + grep -q '^#quarto-code-filename("Terminal")\[' "${derived_typ}"; then + report pass "annotations-derived: a derived name reaches Quarto's filename helper" +else + report fail "annotations-derived: a derived name reaches Quarto's filename helper" \ + "#quarto-code-filename(\"python\")[ and #quarto-code-filename(\"Terminal\")[ calls" +fi + +if [ "$(count_typst_windows "${derived_typ}")" -eq 7 ]; then + report pass "annotations-derived: every block is framed" +else + report fail "annotations-derived: every block is framed" \ + "seven framed blocks in annotations-derived.typ" +fi + +if [ "$(grep -c '#Skylighting(cell-id:' "${derived_typ}" || true)" -eq 3 ]; then + report pass "annotations-derived: Quarto draws the circles, in a callout and a cell too" +else + report fail "annotations-derived: Quarto draws the circles, in a callout and a cell too" \ + "three #Skylighting(cell-id: calls in annotations-derived.typ" +fi + +# A block with no style of its own takes the document style, not the style of +# the block before it. +if grep '^#_cw-window.update' "${derived_typ}" | grep -q 'is-auto: true, style: "windows"' && + grep '^#_cw-window.update' "${derived_typ}" | grep -A1 'style: "windows"' | + tail -n 1 | grep -q 'style: "macos"'; then + report pass "annotations-derived: a style override stays on its own block" +else + report fail "annotations-derived: a style override stays on its own block" \ + "a windows state followed by a macos state" +fi + +if grep -q 'lines-label: "2"' "${derived_typ}"; then + report pass "annotations-derived: the highlighted lines reach the window" +else + report fail "annotations-derived: the highlighted lines reach the window" \ + "lines-label: \"2\" in the window state" +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From 0b2a62a79df3b6f95a38f1c4934581da4d4f3ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:47:28 +0100 Subject: [PATCH 03/14] test: pin Quarto's filename bar on a Typst block that refuses the window --- tests/fixtures/disabled-explicit.qmd | 21 +++++++++++++++++++++ tests/run.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/fixtures/disabled-explicit.qmd diff --git a/tests/fixtures/disabled-explicit.qmd b/tests/fixtures/disabled-explicit.qmd new file mode 100644 index 0000000..1c8faab --- /dev/null +++ b/tests/fixtures/disabled-explicit.qmd @@ -0,0 +1,21 @@ +--- +title: "A block with a filename that refuses the window" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The first block refuses the window, so Quarto draws its own filename bar. +The second block gets the window. + +```{.python filename="off.py" code-window-enabled="false"} +x = 1 # <1> +``` + +1. A value. + +```{.python filename="on.py"} +y = 2 +``` diff --git a/tests/run.sh b/tests/run.sh index 8911483..8cced0f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -659,6 +659,34 @@ else "lines-label: \"2\" in the window state" fi +# ============================================================================ +# A block that refuses the window keeps Quarto's filename bar +# ============================================================================ + +# The state tells the helper to hand the block to Quarto's own helper, which +# still shows the filename and the circles. +render disabled-explicit typst +disabled_typ="${work_dir}/disabled-explicit.typ" +if grep -q '^#_cw-window.update((enabled: false' "${disabled_typ}" && + grep -q '#Skylighting(cell-id:' "${disabled_typ}" && + [ "$(count_typst_windows "${disabled_typ}")" -eq 1 ]; then + report pass "disabled-explicit: the block goes to Quarto's filename helper" +else + report fail "disabled-explicit: the block goes to Quarto's filename helper" \ + "an enabled: false state, one framed block, and a #Skylighting(cell-id: call" +fi + +if command -v pdftotext >/dev/null 2>&1; then + pdftotext "${work_dir}/disabled-explicit.pdf" "${work_dir}/disabled-explicit.txt" + if grep -q 'off.py' "${work_dir}/disabled-explicit.txt" && + grep -q 'on.py' "${work_dir}/disabled-explicit.txt"; then + report pass "disabled-explicit: both filenames show in the PDF" + else + report fail "disabled-explicit: both filenames show in the PDF" \ + "off.py and on.py in the page text of disabled-explicit.pdf" + fi +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From db88a7e4f169498a68b0fcb1f6b235d62c8c726a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:59:55 +0100 Subject: [PATCH 04/14] fix: keep Quarto's annotation circles and list inside the Typst window The window's block rule reached the blocks inside Quarto's circles, which then grew and wrapped below their line, so the rule now only removes the spacing around the code block. The filename node built for a derived name no longer brings a stray empty block, which kept Quarto from pairing the code with its annotation list. --- _extensions/code-window/code-window.lua | 17 +++++++---------- tests/run.sh | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index fac2a34..15e2fbf 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -485,17 +485,12 @@ local function build_typst_function_def() title-bar, ) // Strip code block chrome so content fills flush against the window body. - // set block() provides defaults for Skylighting blocks (explicit fill preserved). + // Quarto's Skylighting block sets its own width, inset, radius and stroke, + // so set block() only removes the spacing around it. A wider rule would + // also reach the blocks inside Quarto's annotation circles. // show raw overrides the document-level raw block styling (fill, radius). { - set block( - width: 100%%, - inset: 8pt, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) + set block(above: 0pt, below: 0pt) show raw.where(block: true): set block( fill: none, width: 100%%, @@ -977,7 +972,9 @@ local function frame_typst_codeblock(block) style = block_style or CONFIG.style, lines_label = lines_label, }), - quarto.DecoratedCodeBlock({ filename = filename, code_block = block }), + -- The constructor returns more than the node, and a call at the end of a + -- table takes every value it returns. The parentheses keep the node only. + (quarto.DecoratedCodeBlock({ filename = filename, code_block = block })), } end diff --git a/tests/run.sh b/tests/run.sh index 8cced0f..4791130 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -600,6 +600,20 @@ else "a #Skylighting(cell-id: call and no circled-number helper of the filter" fi +# The window's own block rules must not reach the circle, or the circle grows +# and wraps below its line. In the page text, the number of the circle then +# ends the line of code it annotates. +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/annotations-explicit.pdf" "${work_dir}/annotations-explicit.txt" + if grep -Eq 'x = 1 +1$' "${work_dir}/annotations-explicit.txt" && + grep -Eq 'y = 2 +2$' "${work_dir}/annotations-explicit.txt"; then + report pass "annotations-explicit: each circle sits on its line of code" + else + report fail "annotations-explicit: each circle sits on its line of code" \ + "x = 1 and y = 2 each followed by their circle number on the same line" + fi +fi + # With idiomatic highlighting, Quarto writes a native block and wraps it in # its own annotation helper, inside the window. render annotations-idiomatic typst @@ -634,11 +648,12 @@ else "seven framed blocks in annotations-derived.typ" fi -if [ "$(grep -c '#Skylighting(cell-id:' "${derived_typ}" || true)" -eq 3 ]; then +if [ "$(grep -c '#Skylighting(cell-id:' "${derived_typ}" || true)" -eq 3 ] && + [ "$(grep -c '^#quarto-annotation-item("' "${derived_typ}" || true)" -eq 3 ]; then report pass "annotations-derived: Quarto draws the circles, in a callout and a cell too" else report fail "annotations-derived: Quarto draws the circles, in a callout and a cell too" \ - "three #Skylighting(cell-id: calls in annotations-derived.typ" + "three #Skylighting(cell-id: calls and three #quarto-annotation-item( calls in annotations-derived.typ" fi # A block with no style of its own takes the document style, not the style of From d85febabdc714eaef2f961cf793582cb6c8ca126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:04:50 +0100 Subject: [PATCH 05/14] fix: keep Quarto's circles on their line with idiomatic highlighting in Typst The window's rule for native code blocks set a full width, which also reached the blocks inside Quarto's circles. --- _extensions/code-window/code-window.lua | 2 +- tests/run.sh | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 15e2fbf..8e8c705 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -489,11 +489,11 @@ local function build_typst_function_def() // so set block() only removes the spacing around it. A wider rule would // also reach the blocks inside Quarto's annotation circles. // show raw overrides the document-level raw block styling (fill, radius). + // It sets no width, which would also reach the blocks in the circles. { set block(above: 0pt, below: 0pt) show raw.where(block: true): set block( fill: none, - width: 100%%, radius: 0pt, stroke: none, above: 0pt, diff --git a/tests/run.sh b/tests/run.sh index 4791130..c1b71f1 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -624,6 +624,17 @@ else "a #quarto-code-annotation( call in annotations-idiomatic.typ" fi +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/annotations-idiomatic.pdf" "${work_dir}/annotations-idiomatic.txt" + if grep -Eq 'x = 1 +1$' "${work_dir}/annotations-idiomatic.txt" && + grep -Eq 'y = 2 +2$' "${work_dir}/annotations-idiomatic.txt"; then + report pass "annotations-idiomatic: each circle sits on its line of code" + else + report fail "annotations-idiomatic: each circle sits on its line of code" \ + "x = 1 and y = 2 each followed by their circle number on the same line" + fi +fi + # ============================================================================ # A block with a derived name goes through Quarto's filename node # ============================================================================ From 2d8d3d9c3eec8ef7490d541b8fad06e0c03d7269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:15:27 +0100 Subject: [PATCH 06/14] test: compile Typst with a format that replaces Quarto's definitions The fixture uses a format whose definitions partial has no filename helper, with one framed block and one block that refuses the window. --- .../_extensions/fake/_extension.yml | 14 + .../_extensions/fake/biblio.typ | 18 ++ .../_extensions/fake/definitions.typ | 257 ++++++++++++++++++ .../_extensions/fake/notes.typ | 10 + .../_extensions/fake/numbering.typ | 23 ++ .../ext-definitions/_extensions/fake/page.typ | 34 +++ .../_extensions/fake/template.typ | 28 ++ .../_extensions/fake/typst-show.typ | 109 ++++++++ .../_extensions/fake/typst-template.typ | 160 +++++++++++ tests/fixtures/ext-definitions/_quarto.yml | 2 + tests/fixtures/ext-definitions/index.qmd | 22 ++ tests/run.sh | 19 ++ 12 files changed, 696 insertions(+) create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/_extension.yml create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/biblio.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/definitions.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/notes.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/numbering.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/page.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/template.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ create mode 100644 tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ create mode 100644 tests/fixtures/ext-definitions/_quarto.yml create mode 100644 tests/fixtures/ext-definitions/index.qmd diff --git a/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml new file mode 100644 index 0000000..3dd92e6 --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml @@ -0,0 +1,14 @@ +title: Fake +author: Test +version: 1.0.0 +contributes: + formats: + typst: + template-partials: + - numbering.typ + - definitions.typ + - typst-template.typ + - page.typ + - typst-show.typ + - notes.typ + - biblio.typ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ b/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ new file mode 100644 index 0000000..2c8f0eb --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ @@ -0,0 +1,18 @@ +$if(citations)$ +$if(csl)$ + +#set bibliography(style: "$csl$") +$elseif(bibliographystyle)$ + +#set bibliography(style: "$bibliographystyle$") +$endif$ +$if(bibliography)$ +$-- Suppress bibliography display when citation-location: margin (consistent with HTML behavior) +$-- Full citations appear in margins; bibliography is loaded but not displayed +$if(suppress-bibliography)$ +#show bibliography: none +$endif$ + +#bibliography(($for(bibliography)$"$bibliography$"$sep$,$endfor$)) +$endif$ +$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ b/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ new file mode 100644 index 0000000..a1f86a3 --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/definitions.typ @@ -0,0 +1,257 @@ +// Some definitions presupposed by pandoc's typst output. +#let content-to-string(content) = { + if content.has("text") { + content.text + } else if content.has("children") { + content.children.map(content-to-string).join("") + } else if content.has("body") { + content-to-string(content.body) + } else if content == [ ] { + " " + } +} + +#let horizontalrule = line(start: (25%,0%), end: (75%,0%)) + +#let endnote(num, contents) = [ + #stack(dir: ltr, spacing: 3pt, super[#num], contents) +] + +#show terms.item: it => block(breakable: false)[ + #text(weight: "bold")[#it.term] + #block(inset: (left: 1.5em, top: -0.4em))[#it.description] +] + +// Some quarto-specific definitions. + +// Style native raw code blocks with default inset and radius +// Default fill is a set rule so a later `set block(fill: ...)` (brand or user) wins +#show raw.where(block: true): set block(fill: luma(230)) +#show raw.where(block: true): it => block( + width: 100%, inset: 8pt, radius: 2pt, stroke: 0.5pt + luma(128), it, +) + +#let block_with_new_content(old_block, new_content) = { + let fields = old_block.fields() + let _ = fields.remove("body") + if fields.at("below", default: none) != none { + // TODO: this is a hack because below is a "synthesized element" + // according to the experts in the typst discord... + fields.below = fields.below.abs + } + block.with(..fields)(new_content) +} + +#let empty(v) = { + if type(v) == str { + // two dollar signs here because we're technically inside + // a Pandoc template :grimace: + v.matches(regex("^\\s*$$")).at(0, default: none) != none + } else if type(v) == content { + if v.at("text", default: none) != none { + return empty(v.text) + } + for child in v.at("children", default: ()) { + if not empty(child) { + return false + } + } + return true + } + +} + +// Subfloats +// This is a technique that we adapted from https://github.com/tingerrr/subpar/ +#let quartosubfloatcounter = counter("quartosubfloatcounter") + +#let quarto_super( + kind: str, + caption: none, + label: none, + supplement: str, + position: none, + subcapnumbering: "(a)", + body, +) = { + context { + let figcounter = counter(figure.where(kind: kind)) + let n-super = figcounter.get().first() + 1 + set figure.caption(position: position) + [#figure( + kind: kind, + supplement: supplement, + caption: caption, + { + show figure.where(kind: kind): set figure(numbering: _ => { + let subfloat-idx = quartosubfloatcounter.get().first() + 1 + subfloat-numbering(n-super, subfloat-idx) + }) + show figure.where(kind: kind): set figure.caption(position: position) + show figure.where(kind: kind): set figure(outlined: false) + + show figure: it => { + let num = numbering(subcapnumbering, n-super, quartosubfloatcounter.get().first() + 1) + show figure.caption: it => block({ + num.slice(2) // I don't understand why the numbering contains output that it really shouldn't, but this fixes it shrug? + [ ] + it.body + }) + + quartosubfloatcounter.step() + it + counter(figure.where(kind: it.kind)).update(n => n - 1) + } + + quartosubfloatcounter.update(0) + body + } + )#label] + } +} + +// callout rendering +// this is a figure show rule because callouts are crossreferenceable +#show figure: it => { + if type(it.kind) != str { + return it + } + let kind_match = it.kind.matches(regex("^quarto-callout-(.*)")).at(0, default: none) + if kind_match == none { + return it + } + let kind = kind_match.captures.at(0, default: "other") + kind = upper(kind.first()) + kind.slice(1) + // now we pull apart the callout and reassemble it with the crossref name and counter + + // when we cleanup pandoc's emitted code to avoid spaces this will have to change + let old_callout = it.body.children.at(1).body.children.at(1) + let old_title_block = old_callout.body.children.at(0) + let children = old_title_block.body.body.children + let old_title = if children.len() == 1 { + children.at(0) // no icon: title at index 0 + } else { + children.at(1) // with icon: title at index 1 + } + + // TODO use custom separator if available + // Use the figure's counter display which handles chapter-based numbering + // (when numbering is a function that includes the heading counter) + let callout_num = it.counter.display(it.numbering) + let new_title = if empty(old_title) { + [#kind #callout_num] + } else { + [#kind #callout_num: #old_title] + } + + let new_title_block = block_with_new_content( + old_title_block, + block_with_new_content( + old_title_block.body, + if children.len() == 1 { + new_title // no icon: just the title + } else { + children.at(0) + new_title // with icon: preserve icon block + new title + })) + + align(left, block_with_new_content(old_callout, + block(below: 0pt, new_title_block) + + old_callout.body.children.at(1))) +} + +// 2023-10-09: #fa-icon("fa-info") is not working, so we'll eval "#fa-info()" instead +#let callout(body: [], title: "Callout", background_color: rgb("#dddddd"), icon: none, icon_color: black, body_background_color: white) = { + block( + breakable: false, + fill: background_color, + stroke: (paint: icon_color, thickness: 0.5pt, cap: "round"), + width: 100%, + radius: 2pt, + block( + inset: 1pt, + width: 100%, + below: 0pt, + block( + fill: background_color, + width: 100%, + inset: 8pt)[#if icon != none [#text(icon_color, weight: 900)[#icon] ]#title]) + + if(body != []){ + block( + inset: 1pt, + width: 100%, + block(fill: body_background_color, width: 100%, inset: 8pt, body)) + } + ) +} + +$if(margin-geometry)$ +// Margin layout support using marginalia package +#import "@preview/marginalia:0.3.1" as marginalia: note, notefigure, wideblock + +// Render footnote as margin note using standard footnote counter +// Used via show rule: #show footnote: it => column-sidenote(it.body) +// The footnote element already steps the counter, so we just display it +#let column-sidenote(body) = { + context { + let num = counter(footnote).display("1") + // Superscript mark in text + super(num) + // Content in margin with matching number + note( + alignment: "baseline", + shift: auto, + counter: none, // We display our own number from footnote counter + )[ + #super(num) #body + ] + } +} + +// Note: Margin citations are now emitted directly from Lua as #note() calls +// with #cite(form: "full") + locator text, preserving citation locators. + +// Utility: compute padding for each side based on side parameter +#let side-pad(side, left-amount, right-amount) = { + let l = if side == "both" or side == "left" or side == "inner" { left-amount } else { 0pt } + let r = if side == "both" or side == "right" or side == "outer" { right-amount } else { 0pt } + (left: l, right: r) +} + +// body-outset: extends ~15% into margin area +#let column-body-outset(side: "both", body) = context { + let r = marginalia.get-right() + let out = 0.15 * (r.sep + r.width) + pad(..side-pad(side, -out, -out), body) +} + +// page-inset: wideblock minus small inset from page boundary +#let column-page-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + // Inset is a small fraction of the extension area (wideblock stops at far) + let left-inset = 0.15 * l.sep + let right-inset = 0.15 * (r.sep + r.width) + wideblock(side: side)[#pad(..side-pad(side, left-inset, right-inset), body)] +} + +// screen-inset: full width minus `far` distance from edges +#let column-screen-inset(side: "both", body) = context { + let l = marginalia.get-left() + let r = marginalia.get-right() + wideblock(side: side)[#pad(..side-pad(side, l.far, r.far), body)] +} + +// screen-inset-shaded: screen-inset with gray background +#let column-screen-inset-shaded(body) = context { + let l = marginalia.get-left() + wideblock(side: "both")[ + #block(fill: luma(245), width: 100%, inset: (x: l.far, y: 1em), body) + ] +} +$endif$ + +$if(highlighting-definitions)$ +// syntax highlighting functions from skylighting: +$highlighting-definitions$ + +$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/notes.typ b/tests/fixtures/ext-definitions/_extensions/fake/notes.typ new file mode 100644 index 0000000..99f136c --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/notes.typ @@ -0,0 +1,10 @@ +$if(notes)$ +#v(1em) +#block[ +#horizontalrule +#set text(size: .88em) +#v(3pt) // otherwise first note marker is swallowed, bug? + +$notes$ +] +$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ b/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ new file mode 100644 index 0000000..e65286b --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ @@ -0,0 +1,23 @@ +// Simple numbering for non-book documents +#let equation-numbering = "(1)" +#let callout-numbering = "1" +#let subfloat-numbering(n-super, subfloat-idx) = { + numbering("1a", n-super, subfloat-idx) +} + +// Theorem configuration for theorion +// Simple numbering for non-book documents (no heading inheritance) +#let theorem-inherited-levels = 0 + +// Theorem numbering format (can be overridden by extensions for appendix support) +// This function returns the numbering pattern to use +#let theorem-numbering(loc) = "1.1" + +// Default theorem render function +#let theorem-render(prefix: none, title: "", full-title: auto, body) = { + if full-title != "" and full-title != auto and full-title != none { + strong[#full-title.] + h(0.5em) + } + body +} diff --git a/tests/fixtures/ext-definitions/_extensions/fake/page.typ b/tests/fixtures/ext-definitions/_extensions/fake/page.typ new file mode 100644 index 0000000..cee094b --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/page.typ @@ -0,0 +1,34 @@ +#set page( + paper: $if(papersize)$"$papersize$"$else$"us-letter"$endif$, +$if(margin-geometry)$ + // Margins handled by marginalia.setup below +$elseif(margin)$ + margin: ($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$), +$else$ + margin: (x: 1.25in, y: 1.25in), +$endif$ + numbering: $if(page-numbering)$"$page-numbering$"$else$none$endif$, + columns: $if(columns)$$columns$$else$1$endif$, +) +$if(logo)$ +#set page(background: align($logo.location$, box(inset: $logo.inset$, image("$logo.path$", width: $logo.width$$if(logo.alt)$, alt: "$logo.alt$"$endif$)))) +$endif$ +$if(margin-geometry)$ +// Configure marginalia page geometry (functions defined in definitions.typ) +#show: marginalia.setup.with( + inner: ( + far: $margin-geometry.inner.far$, + width: $margin-geometry.inner.width$, + sep: $margin-geometry.inner.separation$, + ), + outer: ( + far: $margin-geometry.outer.far$, + width: $margin-geometry.outer.width$, + sep: $margin-geometry.outer.separation$, + ), + top: $if(margin.top)$$margin.top$$else$1.25in$endif$, + bottom: $if(margin.bottom)$$margin.bottom$$else$1.25in$endif$, + book: false, + clearance: $margin-geometry.clearance$, +) +$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/template.typ b/tests/fixtures/ext-definitions/_extensions/fake/template.typ new file mode 100644 index 0000000..0c9d99e --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/template.typ @@ -0,0 +1,28 @@ +$numbering.typ()$ + +$definitions.typ()$ + +$typst-template.typ()$ + +$for(header-includes)$ +$header-includes$ +$endfor$ + +$page.typ()$ + +$typst-show.typ()$ + +$for(include-before)$ +$include-before$ +$endfor$ + +$body$ + +$notes.typ()$ + +$biblio.typ()$ + +$for(include-after)$ +$include-after$ +$endfor$ + diff --git a/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ b/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ new file mode 100644 index 0000000..72fa761 --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ @@ -0,0 +1,109 @@ +#show: doc => article( +$if(title)$ + title: [$title$], +$endif$ +$if(subtitle)$ + subtitle: [$subtitle$], +$endif$ +$if(by-author)$ + authors: ( +$for(by-author)$ +$if(it.name.literal)$ + ( name: [$it.name.literal$], + affiliation: [$for(it.affiliations)$$it.name$$sep$, $endfor$], + email: [$it.email$] ), +$endif$ +$endfor$ + ), +$endif$ +$if(date)$ + date: [$date$], +$endif$ +$if(lang)$ + lang: "$lang$", +$endif$ +$if(region)$ + region: "$region$", +$endif$ +$if(abstract)$ + abstract: [$abstract$], + abstract-title: "$labels.abstract$", +$endif$ +$if(mainfont)$ + font: ("$mainfont$",), +$elseif(brand.typography.base.family)$ + font: $brand.typography.base.family$, +$endif$ +$if(fontsize)$ + fontsize: $fontsize$, +$elseif(brand.typography.base.size)$ + fontsize: $brand.typography.base.size$, +$endif$ +$if(title)$ +$if(brand.typography.headings.family)$ + heading-family: $brand.typography.headings.family$, +$elseif(mainfont)$ + heading-family: ("$mainfont$",), +$endif$ +$if(brand.typography.headings.weight)$ + heading-weight: $brand.typography.headings.weight$, +$endif$ +$if(brand.typography.headings.style)$ + heading-style: "$brand.typography.headings.style$", +$endif$ +$if(brand.typography.headings.color)$ + heading-color: $brand.typography.headings.color$, +$endif$ +$if(brand.typography.headings.line-height)$ + heading-line-height: $brand.typography.headings.line-height$, +$endif$ +$endif$ +$if(section-numbering)$ + sectionnumbering: "$section-numbering$", +$endif$ +$if(mathfont)$ + mathfont: ($for(mathfont)$"$mathfont$",$endfor$), +$endif$ +$if(codefont)$ + codefont: ($for(codefont)$"$codefont$",$endfor$), +$elseif(brand.typography.monospace.family)$ + codefont: $brand.typography.monospace.family$, +$endif$ +$if(linestretch)$ + linestretch: $linestretch$, +$endif$ +$if(thanks)$ + thanks: [$thanks$], +$endif$ +$if(linkcolor)$ + linkcolor: [$linkcolor$], +$endif$ +$if(citecolor)$ + citecolor: [$citecolor$], +$endif$ +$if(filecolor)$ + filecolor: [$filecolor$], +$endif$ +$if(keywords)$ + keywords: ($for(keywords)$"$keywords$",$endfor$), +$endif$ +$if(toc)$ + toc: $toc$, +$endif$ +$if(toc-title)$ + toc_title: [$toc-title$], +$endif$ +$if(toc-indent)$ + toc_indent: $toc-indent$, +$endif$ + toc_depth: $toc-depth$, +$if(lof)$ + lof: true, + lof-title: [$if(crossref.lof-title)$$crossref.lof-title$$else$$quarto.language.crossref-lof-title$$endif$], +$endif$ +$if(lot)$ + lot: true, + lot-title: [$if(crossref.lot-title)$$crossref.lot-title$$else$$quarto.language.crossref-lot-title$$endif$], +$endif$ + doc, +) diff --git a/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ b/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ new file mode 100644 index 0000000..e5ccc37 --- /dev/null +++ b/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ @@ -0,0 +1,160 @@ + +#let article( + title: none, + subtitle: none, + authors: none, + keywords: (), + date: none, + abstract-title: none, + abstract: none, + thanks: none, + cols: 1, + lang: "en", + region: "US", + font: none, + fontsize: 11pt, + title-size: 1.5em, + subtitle-size: 1.25em, + heading-family: none, + heading-weight: "bold", + heading-style: "normal", + heading-color: black, + heading-line-height: 0.65em, + mathfont: none, + codefont: none, + linestretch: 1, + sectionnumbering: none, + linkcolor: none, + citecolor: none, + filecolor: none, + toc: false, + toc_title: none, + toc_depth: none, + toc_indent: 1.5em, + lof: false, + lof-title: none, + lot: false, + lot-title: none, + doc, +) = { + // Set document metadata for PDF accessibility + set document(title: title, keywords: keywords) + set document( + author: authors.map(author => content-to-string(author.name)).join(", ", last: " & "), + ) if authors != none and authors != () + set par( + justify: true, + leading: linestretch * 0.65em + ) + set text(lang: lang, + region: region, + size: fontsize) + set text(font: font) if font != none + show math.equation: set text(font: mathfont) if mathfont != none + show raw: set text(font: codefont) if codefont != none + + set heading(numbering: sectionnumbering) + + show link: set text(fill: rgb(content-to-string(linkcolor))) if linkcolor != none + show ref: set text(fill: rgb(content-to-string(citecolor))) if citecolor != none + show link: this => { + if filecolor != none and type(this.dest) == label { + text(this, fill: rgb(content-to-string(filecolor))) + } else { + text(this) + } + } + + let has-title-block = title != none or (authors != none and authors != ()) or date != none or abstract != none + if has-title-block { + place( + top, + float: true, + scope: "parent", + clearance: 4mm, + block(below: 1em, width: 100%)[ + + #if title != none { + align(center, block(inset: 2em)[ + #set par(leading: heading-line-height) if heading-line-height != none + #set text(font: heading-family) if heading-family != none + #set text(weight: heading-weight) + #set text(style: heading-style) if heading-style != "normal" + #set text(fill: heading-color) if heading-color != black + + #text(size: title-size)[#title #if thanks != none { + footnote(thanks, numbering: "*") + counter(footnote).update(n => n - 1) + }] + #(if subtitle != none { + parbreak() + text(size: subtitle-size)[#subtitle] + }) + ]) + } + + #if authors != none and authors != () { + let count = authors.len() + let ncols = calc.min(count, 3) + grid( + columns: (1fr,) * ncols, + row-gutter: 1.5em, + ..authors.map(author => + align(center)[ + #author.name \ + #author.affiliation \ + #author.email + ] + ) + ) + } + + #if date != none { + align(center)[#block(inset: 1em)[ + #date + ]] + } + + #if abstract != none { + block(inset: 2em)[ + #text(weight: "semibold")[#abstract-title] #h(1em) #abstract + ] + } + ] + ) + } + + if toc { + let title = if toc_title == none { + auto + } else { + toc_title + } + block(above: 0em, below: 2em)[ + #outline( + title: title, + depth: toc_depth, + indent: toc_indent + ); + ] + } + + if lof { + block(above: 0em, below: 2em)[ + #outline(title: lof-title, target: figure.where(kind: "quarto-float-fig")); + ] + } + + if lot { + block(above: 0em, below: 2em)[ + #outline(title: lot-title, target: figure.where(kind: "quarto-float-tbl")); + ] + } + + doc +} + +#set table( + inset: 6pt, + stroke: none +) diff --git a/tests/fixtures/ext-definitions/_quarto.yml b/tests/fixtures/ext-definitions/_quarto.yml new file mode 100644 index 0000000..b8bae58 --- /dev/null +++ b/tests/fixtures/ext-definitions/_quarto.yml @@ -0,0 +1,2 @@ +project: + type: default diff --git a/tests/fixtures/ext-definitions/index.qmd b/tests/fixtures/ext-definitions/index.qmd new file mode 100644 index 0000000..a35936a --- /dev/null +++ b/tests/fixtures/ext-definitions/index.qmd @@ -0,0 +1,22 @@ +--- +title: "A format that replaces Quarto's definitions" +filters: + - code-window +format: + fake-typst: + keep-typ: true +--- + +The format below replaces the definitions partial with a copy that has no +filename helper. The first block gets the window, and the second block +refuses it. + +```{.python filename="a.py"} +x = 1 # <1> +``` + +1. A value. + +```{.python filename="off.py" code-window-enabled="false"} +y = 2 +``` diff --git a/tests/run.sh b/tests/run.sh index c1b71f1..73fa790 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -713,6 +713,25 @@ if command -v pdftotext >/dev/null 2>&1; then fi fi +# ============================================================================ +# A format that replaces Quarto's definitions still compiles +# ============================================================================ + +# Quarto adds its own helpers to a format that lacks them, unless the +# document defines one of the same name. The filter defines the filename +# helper, so the document has to compile without Quarto's copy of it. +ext_dir="${work_dir}/ext-definitions" +cp -R "${tests_dir}/fixtures/ext-definitions" "${ext_dir}" +cp -R "${work_dir}/_extensions/code-window" "${ext_dir}/_extensions/code-window" +render ext-definitions fake-typst +if grep -q '#Skylighting(cell-id:' "${ext_dir}/index.typ" && + [ "$(count_typst_windows "${ext_dir}/index.typ")" -eq 1 ]; then + report pass "ext-definitions: the document compiles with the window and the circles" +else + report fail "ext-definitions: the document compiles with the window and the circles" \ + "one framed block and a #Skylighting(cell-id: call in index.typ" +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From f7322808bb490d4ff8a046a52c7f96708468c29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:32:51 +0100 Subject: [PATCH 07/14] fix: draw one border around the Typst window Inside the window, Quarto's code blocks lose their own radius and outline through quarto-code-block-style, and get them back after the window. --- _extensions/code-window/code-window.lua | 22 +++++++++------------- tests/run.sh | 10 ++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 8e8c705..6e8d455 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -387,6 +387,7 @@ local function build_typst_function_def() let surface-fill = color.mix((fg, 5%%), (page-bg, 95%%)) let muted-colour = color.mix((fg, 50%%), (page-bg, 50%%)) let label-colour = color.mix((fg, 65%%), (page-bg, 35%%)) + let outer-code-style = quarto-code-block-style.get() let filename-label = if filename != none { text( @@ -484,22 +485,17 @@ local function build_typst_function_def() sticky: true, title-bar, ) - // Strip code block chrome so content fills flush against the window body. - // Quarto's Skylighting block sets its own width, inset, radius and stroke, - // so set block() only removes the spacing around it. A wider rule would - // also reach the blocks inside Quarto's annotation circles. - // show raw overrides the document-level raw block styling (fill, radius). - // It sets no width, which would also reach the blocks in the circles. + // The window draws the border, so Quarto's code blocks inside it lose + // their own radius and outline, and get them back after the window. + // set block() only removes the spacing around the code block: a wider + // rule would also reach the blocks inside Quarto's annotation circles. + // show raw removes the fill of native code blocks. { set block(above: 0pt, below: 0pt) - show raw.where(block: true): set block( - fill: none, - radius: 0pt, - stroke: none, - above: 0pt, - below: 0pt, - ) + show raw.where(block: true): set block(fill: none) + quarto-code-block-style.update((..outer-code-style, radius: 0pt, stroke: none)) content + quarto-code-block-style.update(outer-code-style) } }, ) diff --git a/tests/run.sh b/tests/run.sh index 73fa790..613943d 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -614,6 +614,16 @@ if command -v pdftotext >/dev/null 2>&1; then fi fi +# The window draws the border, so it takes Quarto's outline off the code blocks +# inside it, and puts it back after them. +if grep -q 'quarto-code-block-style.update((..outer-code-style, radius: 0pt, stroke: none))' "${explicit_typ}" && + grep -q 'quarto-code-block-style.update(outer-code-style)' "${explicit_typ}"; then + report pass "annotations-explicit: the window removes Quarto's outline inside it" +else + report fail "annotations-explicit: the window removes Quarto's outline inside it" \ + "an update that removes the outline and one that restores it, in the window function" +fi + # With idiomatic highlighting, Quarto writes a native block and wraps it in # its own annotation helper, inside the window. render annotations-idiomatic typst From 2062abdaef83b209116e7e6bc7870872b9330c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:35:43 +0100 Subject: [PATCH 08/14] docs: describe the Typst window on Quarto's native annotations The hot-fix pages go, the reference explains how the window replaces Quarto's filename helper, and the extension now needs Quarto 1.12. --- CHANGELOG.md | 5 +++ README.md | 2 +- _extensions/code-window/_extension.yml | 2 +- docs/examples.qmd | 42 ++------------------------ docs/index.qmd | 4 ++- docs/reference.qmd | 38 ++++++----------------- 6 files changed, 22 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3598cf..8b6db8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- refactor: Remove the Typst code-annotations and Skylighting hot-fixes and the `hotfix` option, as Quarto 1.12 draws code annotations and filenames in Typst itself. +- feat: Draw the Typst window around Quarto's own filename helper, so the annotation circles, their links and their text alternatives come from Quarto. +- feat: Show Quarto's filename bar on a Typst block with a `filename` that sets `code-window-enabled` to `false`. Before, such a block showed no filename at all. +- refactor: The Typst wrapper function no longer takes `annotations`, `bg-colour` or `block-id`. + ## 1.8.3 (2026-11-12) - fix: The HTML dependency folder now uses the extension version, so browsers load the new files after an upgrade. (#88) diff --git a/README.md b/README.md index 15d5def..432a604 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ If you're using version control, you will want to check in this directory. ## Documentation -The full documentation lives at : every option and per-block attribute, the three window styles, the folding, and the Typst hot-fixes. +The full documentation lives at : every option and per-block attribute, the three window styles, and the folding. [`example.qmd`](example.qmd) is a short, standalone starting point you can copy. diff --git a/_extensions/code-window/_extension.yml b/_extensions/code-window/_extension.yml index ec74da5..3858797 100644 --- a/_extensions/code-window/_extension.yml +++ b/_extensions/code-window/_extension.yml @@ -1,7 +1,7 @@ title: Code Window author: Mickaël Canouil version: 1.8.3 -quarto-required: ">=1.10.19" +quarto-required: ">=1.12.0" contributes: filters: - at: pre-quarto diff --git a/docs/examples.qmd b/docs/examples.qmd index 411de5d..0397850 100644 --- a/docs/examples.qmd +++ b/docs/examples.qmd @@ -216,7 +216,7 @@ See [Executed cells](reference.qmd#executed-cells) for the option itself. ## Renaming the Typst function Under Typst the chrome is a call to a function the extension writes into the document. -`wrapper` renames that function and the helper functions beside it: +`wrapper` renames that function: ```{.yaml filename="_quarto.yml"} extensions: @@ -224,48 +224,10 @@ extensions: wrapper: "my-window" ``` -Each block then emits `#my-window(...)` in place of `#code-window(...)`, and the annotation helpers become `my-window-annotation-item` and `my-window-circled-number`. +The document then defines `#let my-window(...)` in place of `#let code-window(...)`, and the filename helper of Quarto calls `my-window`. Rename it when the document already defines a function called `code-window`. The option does nothing in HTML, which draws its chrome from the stylesheet and the script instead. -## The Typst hot-fixes - -The two hot-fixes act on Typst output only. -Each one is on by default. -A boolean turns one off: - -```{.yaml filename="_quarto.yml"} -extensions: - code-window: - hotfix: - code-annotations: true - skylighting: false -``` - -A map adds a version at which the hot-fix switches itself off: - -```{.yaml filename="_quarto.yml"} -extensions: - code-window: - hotfix: - skylighting: - enabled: true - quarto-version: "1.11.0" -``` - -The extension compares that string with the running Quarto. -At or above the named version the hot-fix stays out, whatever `enabled` says. -An upgrade thus retires the hot-fix with no further edit. - -`code-annotations` and `skylighting` are linked. - -`code-annotations` and `skylighting` work together: the first resolves the annotations, and the second supplies the rule that draws the circled marker beside an annotated line. -Turn `skylighting` off, and the numbered list stays under the block while every marker inside it goes, along with the block styling and the inline code backgrounds the same module supplies. - -Every hot-fix follows `enabled`: with the filter off, none of them acts, and the Typst document comes out as Quarto writes it. - -See [Hot-fixes](reference.qmd#hot-fixes) for what each one corrects. - ## Source The repository ships a short, standalone starting point you can copy: [`example.qmd`](https://github.com/mcanouil/quarto-code-window/blob/main/example.qmd). diff --git a/docs/index.qmd b/docs/index.qmd index 2b42bc7..7e6df71 100644 --- a/docs/index.qmd +++ b/docs/index.qmd @@ -62,7 +62,9 @@ See the [Reference](reference.qmd) for every option, and the [Examples](examples HTML formats, and Typst. In HTML the chrome is assembled at page load by a script the extension injects, so the frame needs JavaScript. -Under Typst the chrome is drawn by a wrapper function, and the extension carries hot-fixes for a few Quarto behaviours that Typst output still needs; the [Reference](reference.qmd#hot-fixes) lists them and when they switch themselves off. +In Typst, a wrapper function draws the chrome around the code block that Quarto writes. +Quarto draws the code annotations, and the window frames them. +The [Reference](reference.qmd#how-the-chrome-is-applied) tells how. ## Links diff --git a/docs/reference.qmd b/docs/reference.qmd index 4e42542..7181487 100644 --- a/docs/reference.qmd +++ b/docs/reference.qmd @@ -1,7 +1,7 @@ --- title: "Reference" subtitle: "Every option the code-window extension accepts." -description: "The complete code-window configuration: the document options, the per-block attributes, the window styles, and the Typst hot-fixes." +description: "The complete code-window configuration: the document options, the per-block attributes, and the window styles." --- ## Enabling the filter @@ -32,7 +32,6 @@ extensions: | `collapse` | boolean or string | `false` | Wrap every block in a `
`. `open` renders it expanded, `closed` or `true` collapsed. HTML only. | | `lines-label` | boolean | `true` | Show a chip beside the filename with the highlighted-line spec. | | `wrapper` | string | `code-window` | The Typst function used to draw the chrome. | -| `hotfix` | object | all on | The Typst hot-fixes below. | : Document and project options. {.striped .hover tbl-colwidths="[18,16,12,54]"} @@ -45,7 +44,7 @@ extensions: | Attribute | Type | Description | | --- | --- | --- | -| `code-window-enabled` | boolean | Whether this block gets chrome. Annotations still render when it does not. | +| `code-window-enabled` | boolean | Whether this block gets chrome. Annotations still render when it does not. In Typst, a block with a `filename` then shows the filename bar of Quarto. | | `code-window-no-auto-filename` | boolean | Suppress the derived filename for this block alone. | | `code-window-auto-label` | string | Frame this block with this label, styled as a derived filename. Works when `auto-filename` is `false`. | | `code-window-style` | string | Override the style for this block: `macos`, `windows`, `default`. | @@ -102,7 +101,14 @@ Output named that way is always framed, whatever this option says. In HTML the extension marks blocks and injects a stylesheet and a script. The script runs at page load and builds the title bar, the fold, and the line chip from those marks, so all three need JavaScript. -In Typst the chrome is a call to the `wrapper` function, emitted into the document, and needs nothing at read time. +In Typst, Quarto writes each block that has a filename as a call to its `quarto-code-filename` helper. +The extension replaces this helper with one that calls the `wrapper` function. +A block that gets its name from its language or from `code-window-auto-label` gets the same call, because the extension gives it the filename node of Quarto. +Before each block, the extension sets the style and the line chip of the window in a Typst state. +Quarto draws the code annotations, and the window frames them. +Inside the window, the code block has no outline of its own, so the window border is the only one. +The chrome needs nothing at read time. +This needs Quarto 1.12 or newer. ### Theming variables @@ -127,30 +133,6 @@ To change the title bar colour, set the variable in the theme: } ``` -## Hot-fixes - -Typst output needs a few corrections that Quarto does not yet make itself. -Each is on by default and each can be given a version at which it switches itself off, so an upgrade retires it without a code change. -All of them follow `enabled`, so a document that turns the filter off takes no hot-fix at all. - -| Hot-fix | What it corrects | -| --- | --- | -| `code-annotations` | Code annotations in Typst output. | -| `skylighting` | Block styling and inline code backgrounds in Typst output. | - -: The Typst hot-fixes. {.striped .hover tbl-colwidths="[26,74]"} - -```yaml -extensions: - code-window: - hotfix: - skylighting: - enabled: true - quarto-version: "1.11.0" -``` - -They exist for [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/issues/14170) and will be removed once Quarto covers the same ground. - ## Interactions ::: {.callout-caution} From fb4c35e94231831f7f393361ec95d7c7e2268e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:45:26 +0100 Subject: [PATCH 09/14] fix: frame Typst blocks in layouts and keep a document's own window function The code of an executed cell with a layout stays in its window, and an annotated block in a layout keeps its list in the same cell, so Quarto still pairs them. The filename helper clears the window state it reads, so a block the filter does not visit, such as one in a note, takes no state from the block before it. A document that defines the window function keeps its own, and the filter still defines the state and the filename helper after it. --- _extensions/code-window/code-window.lua | 72 ++++++++++++++++--------- tests/fixtures/layout-annotations.qmd | 24 +++++++++ tests/fixtures/layout-cell.qmd | 31 +++++++++++ tests/fixtures/own-wrapper.qmd | 18 +++++++ tests/fixtures/state-reset.qmd | 24 +++++++++ tests/run.sh | 51 ++++++++++++++++++ 6 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 tests/fixtures/layout-annotations.qmd create mode 100644 tests/fixtures/layout-cell.qmd create mode 100644 tests/fixtures/own-wrapper.qmd create mode 100644 tests/fixtures/state-reset.qmd diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 6e8d455..dbce6ca 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -345,7 +345,9 @@ local TYPST_COLOUR_HELPERS = [==[ --- filter keeps Quarto's own helper under another name, then replaces it with --- one that reads the window state the filter sets before each block. A block --- that refuses the window, or one the filter never visited, goes to Quarto's ---- helper. "code-window" is renamed with the wrapper option. +--- helper. Each call clears the state it read, so a block the filter never +--- visited does not take the state of the block before it. "code-window" is +--- renamed with the wrapper option. local TYPST_FILENAME_HELPER = [==[ #let _cw-window = state("cw-window", none) @@ -353,6 +355,7 @@ local TYPST_FILENAME_HELPER = [==[ #let quarto-code-filename(filename, body) = context { let s = _cw-window.get() + _cw-window.update(none) if s == none or not s.enabled { _cw-native-filename(filename, body) } else { @@ -370,8 +373,10 @@ local TYPST_FILENAME_HELPER = [==[ --- Build the complete Typst code-window function definition: the colour --- helpers, the window function, and the replacement for Quarto's filename --- helper, in that order. +--- @param with_window boolean Whether to define the window function, which a +--- document that defines its own does not need --- @return string Typst function definition(s) -local function build_typst_function_def() +local function build_typst_function_def(with_window) local fn_def = string.format([==[ #let code-window( content, @@ -503,6 +508,9 @@ local function build_typst_function_def() } ]==]) + if not with_window then + fn_def = '' + end return TYPST_COLOUR_HELPERS .. fn_def .. TYPST_FILENAME_HELPER end @@ -985,28 +993,36 @@ end --- @return pandoc.Blocks local function frame_typst_blocks(blocks, in_layout) local result = pandoc.Blocks({}) - for _, blk in ipairs(blocks) do - if blk.t == 'CodeBlock' then - local framed = frame_typst_codeblock(blk) - if in_layout and #framed > 1 then - result:insert(pandoc.Div(framed)) - else - result:extend(framed) + local i = 1 + -- Insert the framed blocks of one code block. In a layout, they share one + -- Div with the annotation list after them, so the layout keeps one cell for + -- them and Quarto still pairs the block with its list. + local function insert_framed(framed) + if in_layout and #framed > 1 then + local next_blk = blocks[i + 1] + if next_blk and next_blk.t == 'OrderedList' then + table.insert(framed, next_blk) + i = i + 1 end + result:insert(pandoc.Div(framed)) + else + result:extend(framed) + end + end + while i <= #blocks do + local blk = blocks[i] + if blk.t == 'CodeBlock' then + insert_framed(frame_typst_codeblock(blk)) elseif blk.t == 'Div' and is_decorated_codeblock(blk) then local state = decorated_window_state(blk) - if state and in_layout then - result:insert(pandoc.Div({ state, blk })) - else - if state then - result:insert(state) - end - result:insert(blk) - end + insert_framed(state and { state, blk } or { blk }) elseif blk.t == 'Div' then - local is_layout = blk.attributes['layout-ncol'] + -- The layout of an executed cell places its outputs. Its code is the + -- first block of the cell, and its window state goes before the cell. + local is_layout = not blk.classes:includes('cell') and ( + blk.attributes['layout-ncol'] or blk.attributes['layout-nrow'] - or blk.attributes['layout'] + or blk.attributes['layout']) blk.content = frame_typst_blocks(blk.content, is_layout) -- Quarto reads the code of an executed cell as the first block of the -- cell, so the window state of that code goes before the cell instead. @@ -1035,6 +1051,7 @@ local function frame_typst_blocks(blocks, in_layout) else result:insert(blk) end + i = i + 1 end return result end @@ -1083,20 +1100,25 @@ function Pandoc(doc) -- Set the window state before every block Quarto draws with a filename. doc.blocks = frame_typst_blocks(doc.blocks) - -- Guard: check if the function definition is already present. - local fn_pattern = '#let ' .. CONFIG.typst_wrapper - for _, blk in ipairs(doc.blocks) do + -- A document that defines the window function keeps its own. The state and + -- the filename helper are still needed, as every framed block uses them, + -- and they go after that definition, because Typst only sees the names + -- defined before. + local fn_pattern = '#let ' .. CONFIG.typst_wrapper .. '(' + local insert_at = 1 + for idx, blk in ipairs(doc.blocks) do if blk.t == 'RawBlock' and blk.format == 'typst' and blk.text:find(fn_pattern, 1, true) then - return doc + insert_at = idx + 1 + break end end - local fn_def = build_typst_function_def() + local fn_def = build_typst_function_def(insert_at == 1) if CONFIG.typst_wrapper ~= 'code-window' then fn_def = fn_def:gsub('code%-window%(', CONFIG.typst_wrapper .. '(') end - table.insert(doc.blocks, 1, pandoc.RawBlock('typst', fn_def)) + table.insert(doc.blocks, insert_at, pandoc.RawBlock('typst', fn_def)) return doc end diff --git a/tests/fixtures/layout-annotations.qmd b/tests/fixtures/layout-annotations.qmd new file mode 100644 index 0000000..e4b6616 --- /dev/null +++ b/tests/fixtures/layout-annotations.qmd @@ -0,0 +1,24 @@ +--- +title: "Annotations in a layout" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds an annotated block and its list, then a second block. + +::: {layout-ncol="2"} + +```{.python} +c = 1 # <1> +``` + +1. In the layout. + +```{.r} +d <- 2 +``` + +::: diff --git a/tests/fixtures/layout-cell.qmd b/tests/fixtures/layout-cell.qmd new file mode 100644 index 0000000..ecdf05f --- /dev/null +++ b/tests/fixtures/layout-cell.qmd @@ -0,0 +1,31 @@ +--- +title: "An executed cell with a layout" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The div below has the shape an engine gives an executed cell whose outputs +sit side by side. The code stays in its window, above the outputs. + +::: {.cell layout-ncol="2"} + +```{.python .cell-code} +b = 1 +``` + +::: {.cell-output .cell-output-stdout} + + out1 + +::: + +::: {.cell-output .cell-output-stdout} + + out2 + +::: + +::: diff --git a/tests/fixtures/own-wrapper.qmd b/tests/fixtures/own-wrapper.qmd new file mode 100644 index 0000000..b976493 --- /dev/null +++ b/tests/fixtures/own-wrapper.qmd @@ -0,0 +1,18 @@ +--- +title: "A document that defines its own window function" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The document defines the window function itself, so the filter keeps it. + +```{=typst} +#let code-window(content, ..args) = content +``` + +```{.python filename="x.py"} +x = 1 +``` diff --git a/tests/fixtures/state-reset.qmd b/tests/fixtures/state-reset.qmd new file mode 100644 index 0000000..bf683db --- /dev/null +++ b/tests/fixtures/state-reset.qmd @@ -0,0 +1,24 @@ +--- +title: "The window state of a block the filter does not visit" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The first block highlights a line. The block in the note comes after it, and +the filter does not visit notes, so the note block keeps Quarto's own bar. + +```{.python filename="first.py" code-line-numbers="2"} +first = 1 +second = 2 +``` + +Text with a note.[^1] + +[^1]: A note. + + ```{.python filename="note.py"} + n = 1 + ``` diff --git a/tests/run.sh b/tests/run.sh index 613943d..4d173f3 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -742,6 +742,57 @@ else "one framed block and a #Skylighting(cell-id: call in index.typ" fi +# ============================================================================ +# Layouts, notes, and a window function of the document's own +# ============================================================================ + +# The code of an executed cell with a layout stays in its window, and the +# window is not left empty in a cell of the layout. +render layout-cell typst +layout_cell_typ="${work_dir}/layout-cell.typ" +if [ "$(count_typst_windows "${layout_cell_typ}")" -eq 1 ] && + ! grep -A1 '^#quarto-code-filename("python")\[' "${layout_cell_typ}" | grep -qx '\]'; then + report pass "layout-cell: the code of the cell stays in its window" +else + report fail "layout-cell: the code of the cell stays in its window" \ + "one framed block, and no empty #quarto-code-filename(\"python\")[ ] call" +fi + +# In a layout, Quarto still pairs an annotated block with its list. +render layout-annotations typst +if [ "$(grep -c '^#quarto-annotation-item("' "${work_dir}/layout-annotations.typ" || true)" -eq 1 ]; then + report pass "layout-annotations: Quarto pairs the block with its list" +else + report fail "layout-annotations: Quarto pairs the block with its list" \ + "one #quarto-annotation-item( call in layout-annotations.typ" +fi + +# The filter does not visit notes, so a block in a note must not take the +# window state of the block before it, here its line chip. +render state-reset typst +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/state-reset.pdf" "${work_dir}/state-reset.txt" + if grep -Eq 'first\.py +2' "${work_dir}/state-reset.txt" && + grep -q 'note\.py' "${work_dir}/state-reset.txt" && + ! grep -Eq 'note\.py +2' "${work_dir}/state-reset.txt"; then + report pass "state-reset: a block the filter does not visit takes no window state" + else + report fail "state-reset: a block the filter does not visit takes no window state" \ + "a 2 chip beside first.py and none beside note.py" + fi +fi + +# A document that defines the window function keeps its own, and still +# compiles, which the render checks. +render own-wrapper typst +if [ "$(grep -c '^#let code-window(' "${work_dir}/own-wrapper.typ" || true)" -eq 1 ] && + grep -q '^#let code-window(content, ..args) = content' "${work_dir}/own-wrapper.typ"; then + report pass "own-wrapper: the document keeps its own window function" +else + report fail "own-wrapper: the document keeps its own window function" \ + "one #let code-window( definition, the one the document wrote" +fi + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From b5cdcbdac7c992d8a19df050bec7bdf54c684e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:19:46 +0100 Subject: [PATCH 10/14] refactor: simplify the Typst framing pass Framing a block now returns its window state and its node apart, so an executed cell takes the state of its code without a search through generated text. The inline-code pass is wired directly, the filter reuses the vendored Typst string escaper, the filename helper clears the state only when one is set, and example.qmd no longer documents the removed hotfix option. --- .../code-window/_modules/inline-code.lua | 4 +- _extensions/code-window/code-window.lua | 130 ++++++++++-------- _extensions/code-window/main.lua | 18 +-- example.qmd | 19 +-- tests/run.sh | 20 ++- 5 files changed, 93 insertions(+), 98 deletions(-) diff --git a/_extensions/code-window/_modules/inline-code.lua b/_extensions/code-window/_modules/inline-code.lua index 1c85bb4..037ee64 100644 --- a/_extensions/code-window/_modules/inline-code.lua +++ b/_extensions/code-window/_modules/inline-code.lua @@ -53,7 +53,5 @@ local function process_inline_code(doc) end return { - filters = { - { Pandoc = process_inline_code }, - }, + Pandoc = process_inline_code, } diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index dbce6ca..f4234b7 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -13,7 +13,6 @@ local EXTENSION_NAME = 'code-window' local str = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/string.lua'):gsub('%.lua$', '')) -local log = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/logging.lua'):gsub('%.lua$', '')) local meta_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/metadata.lua'):gsub('%.lua$', '')) local pdoc = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/pandoc-helpers.lua'):gsub('%.lua$', '')) local html_mod = require(quarto.utils.resolve_path('_vendor/quarto-lua-modules/html.lua'):gsub('%.lua$', '')) @@ -355,7 +354,9 @@ local TYPST_FILENAME_HELPER = [==[ #let quarto-code-filename(filename, body) = context { let s = _cw-window.get() - _cw-window.update(none) + if s != none { + _cw-window.update(none) + } if s == none or not s.enabled { _cw-native-filename(filename, body) } else { @@ -377,6 +378,9 @@ local TYPST_FILENAME_HELPER = [==[ --- document that defines its own does not need --- @return string Typst function definition(s) local function build_typst_function_def(with_window) + if not with_window then + return TYPST_COLOUR_HELPERS .. TYPST_FILENAME_HELPER + end local fn_def = string.format([==[ #let code-window( content, @@ -508,9 +512,6 @@ local function build_typst_function_def(with_window) } ]==]) - if not with_window then - fn_def = '' - end return TYPST_COLOUR_HELPERS .. fn_def .. TYPST_FILENAME_HELPER end @@ -518,13 +519,6 @@ end -- TYPST PROCESSING -- ============================================================================ ---- Escape a string for embedding in a Typst double-quoted string literal. ---- @param s string ---- @return string -local function typst_escape_string(s) - return (s:gsub('\\', '\\\\'):gsub('"', '\\"')) -end - --- @class WindowState --- @field enabled boolean Whether the block gets the window --- @field is_auto boolean Whether the name was derived rather than written @@ -539,7 +533,7 @@ end local function typst_window_state(params) local lines_label = 'none' if params.lines_label and params.lines_label ~= '' then - lines_label = '"' .. typst_escape_string(params.lines_label) .. '"' + lines_label = '"' .. str.escape_typst_string(params.lines_label) .. '"' end return pandoc.RawBlock('typst', string.format( '#_cw-window.update((enabled: %s, is-auto: %s, style: "%s", lines-label: %s))', @@ -953,33 +947,48 @@ end --- with a filename before this filter ran, and the window state goes before it. --- Every other block is returned as it is, its own attributes taken off. --- @param block pandoc.CodeBlock ---- @return pandoc.Block[] Blocks to put in place of the code block +--- @return pandoc.RawBlock|nil state Window state to put before the node +--- @return pandoc.Block node Block to put in place of the code block local function frame_typst_codeblock(block) if is_quarto_block(block) then - return { block } + return nil, block end -- Check this block's own attributes against the CodeBlock group of the -- schema before anything below reads them. local resolved, auto_label = resolve_and_take(block) if is_unnamed_cell_output(block) then - return { block } + return nil, block end local filename, is_auto, block_style, lines_label = resolve_window_params(block, resolved, auto_label) if not is_auto or str.is_empty(filename) then - return { block } + return nil, block end - return { - typst_window_state({ - enabled = true, - is_auto = true, - style = block_style or CONFIG.style, - lines_label = lines_label, - }), - -- The constructor returns more than the node, and a call at the end of a - -- table takes every value it returns. The parentheses keep the node only. - (quarto.DecoratedCodeBlock({ filename = filename, code_block = block })), - } + local state = typst_window_state({ + enabled = true, + is_auto = true, + style = block_style or CONFIG.style, + lines_label = lines_label, + }) + -- The constructor returns more than the node. The parentheses keep the node. + return state, (quarto.DecoratedCodeBlock({ filename = filename, code_block = block })) +end + +--- Frame one block when it is a code block, or Quarto's filename node. +--- @param blk pandoc.Block|nil +--- @return pandoc.RawBlock|nil state Window state to put before the node +--- @return pandoc.Block|nil node Block to put in place, or nil when blk holds no code +local function frame_typst_code(blk) + if not blk then + return nil, nil + end + if blk.t == 'CodeBlock' then + return frame_typst_codeblock(blk) + end + if blk.t == 'Div' and is_decorated_codeblock(blk) then + return decorated_window_state(blk), blk + end + return nil, nil end --- Walk a list of blocks for Typst and set the window state before each block @@ -994,44 +1003,47 @@ end local function frame_typst_blocks(blocks, in_layout) local result = pandoc.Blocks({}) local i = 1 - -- Insert the framed blocks of one code block. In a layout, they share one - -- Div with the annotation list after them, so the layout keeps one cell for - -- them and Quarto still pairs the block with its list. - local function insert_framed(framed) - if in_layout and #framed > 1 then + while i <= #blocks do + local blk = blocks[i] + local state, node = frame_typst_code(blk) + if node and state and in_layout then + -- In a layout, the state, the node and the annotation list after them + -- share one Div, so the layout keeps one cell for them and Quarto still + -- pairs the block with its list. + local cell = { state, node } local next_blk = blocks[i + 1] if next_blk and next_blk.t == 'OrderedList' then - table.insert(framed, next_blk) + table.insert(cell, next_blk) i = i + 1 end - result:insert(pandoc.Div(framed)) - else - result:extend(framed) - end - end - while i <= #blocks do - local blk = blocks[i] - if blk.t == 'CodeBlock' then - insert_framed(frame_typst_codeblock(blk)) - elseif blk.t == 'Div' and is_decorated_codeblock(blk) then - local state = decorated_window_state(blk) - insert_framed(state and { state, blk } or { blk }) + result:insert(pandoc.Div(cell)) + elseif node then + if state then + result:insert(state) + end + result:insert(node) + elseif blk.t == 'Div' and blk.classes:includes('cell') then + -- Quarto reads the code of an executed cell as the first block of the + -- cell, so the window state of that code goes before the cell. The + -- layout of a cell places its outputs, so the cell is not a layout here. + local first_state, first_node = frame_typst_code(blk.content[1]) + if first_node then + local rest = blk.content:clone() + rest:remove(1) + blk.content = frame_typst_blocks(rest) + blk.content:insert(1, first_node) + if first_state then + result:insert(first_state) + end + else + blk.content = frame_typst_blocks(blk.content) + end + result:insert(blk) elseif blk.t == 'Div' then - -- The layout of an executed cell places its outputs. Its code is the - -- first block of the cell, and its window state goes before the cell. - local is_layout = not blk.classes:includes('cell') and ( - blk.attributes['layout-ncol'] + local is_layout = blk.attributes['layout-ncol'] or blk.attributes['layout-nrow'] - or blk.attributes['layout']) + or blk.attributes['layout'] blk.content = frame_typst_blocks(blk.content, is_layout) - -- Quarto reads the code of an executed cell as the first block of the - -- cell, so the window state of that code goes before the cell instead. - local first = blk.content[1] - if blk.classes:includes('cell') and first - and first.t == 'RawBlock' and first.text:find('#_cw-window.update', 1, true) then - result:insert(first) - blk.content:remove(1) - end result:insert(blk) elseif blk.t == 'BlockQuote' then blk.content = frame_typst_blocks(blk.content) diff --git a/_extensions/code-window/main.lua b/_extensions/code-window/main.lua index ed10d01..9270ff4 100644 --- a/_extensions/code-window/main.lua +++ b/_extensions/code-window/main.lua @@ -117,17 +117,13 @@ local filters = { -- The inline-code box uses the colour helpers that code_window.Pandoc -- declares, so it runs only where that pass draws chrome. -for _, subfilter in ipairs(inline_code.filters) do - local wrapped = {} - for element_type, handler in pairs(subfilter) do - wrapped[element_type] = function(...) - if not code_window.draws_chrome() then - return nil - end - return handler(...) +table.insert(filters, { + Pandoc = function(doc) + if not code_window.draws_chrome() then + return nil end - end - table.insert(filters, wrapped) -end + return inline_code.Pandoc(doc) + end, +}) return filters diff --git a/example.qmd b/example.qmd index ebe109c..d1ffec7 100644 --- a/example.qmd +++ b/example.qmd @@ -195,9 +195,7 @@ print("Default style window") ## Code Annotations ::: {.callout-note} -Typst code-annotations support and `filename` attribute handling are temporary hot-fixes. -They will be removed once Quarto natively supports these features (see [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)). -The extension will then focus on **auto-filename** and **code-window-style** features. +In Typst, Quarto 1.12 draws the code annotations and the window frames them (see [quarto-dev/quarto-cli#14170](https://github.com/quarto-dev/quarto-cli/pull/14170)). ::: Code annotations work standalone and together with code-window styling. @@ -272,21 +270,6 @@ extensions: auto-filename: true style: "macos" wrapper: "code-window" - hotfix: - code-annotations: true - skylighting: true -``` - -Each hotfix accepts either a boolean or a map with `enabled` and `quarto-version` keys for per-hotfix version thresholds: - -```yaml -extensions: - code-window: - hotfix: - code-annotations: - quarto-version: "1.11.0" - skylighting: - enabled: false ``` Override per block with the `code-window-style` attribute: diff --git a/tests/run.sh b/tests/run.sh index 4d173f3..b3f73b8 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -61,6 +61,16 @@ count_typst_windows() { grep -c '^#_cw-window.update((enabled: true' "$1" || true } +# Succeed when the page text of a rendered fixture ends the lines "x = 1" and +# "y = 2" with the numbers of their annotation circles, which is where Quarto +# draws them. Needs pdftotext from poppler. +# $1 fixture base name +circles_on_lines() { + pdftotext -layout "${work_dir}/$1.pdf" "${work_dir}/$1.txt" + grep -Eq 'x = 1 +1$' "${work_dir}/$1.txt" && + grep -Eq 'y = 2 +2$' "${work_dir}/$1.txt" +} + # Succeed when the first line that matches one pattern comes before the first # line that matches another. # $1 rendered file. $2 earlier pattern. $3 later pattern. @@ -604,9 +614,7 @@ fi # and wraps below its line. In the page text, the number of the circle then # ends the line of code it annotates. if command -v pdftotext >/dev/null 2>&1; then - pdftotext -layout "${work_dir}/annotations-explicit.pdf" "${work_dir}/annotations-explicit.txt" - if grep -Eq 'x = 1 +1$' "${work_dir}/annotations-explicit.txt" && - grep -Eq 'y = 2 +2$' "${work_dir}/annotations-explicit.txt"; then + if circles_on_lines annotations-explicit; then report pass "annotations-explicit: each circle sits on its line of code" else report fail "annotations-explicit: each circle sits on its line of code" \ @@ -635,9 +643,7 @@ else fi if command -v pdftotext >/dev/null 2>&1; then - pdftotext -layout "${work_dir}/annotations-idiomatic.pdf" "${work_dir}/annotations-idiomatic.txt" - if grep -Eq 'x = 1 +1$' "${work_dir}/annotations-idiomatic.txt" && - grep -Eq 'y = 2 +2$' "${work_dir}/annotations-idiomatic.txt"; then + if circles_on_lines annotations-idiomatic; then report pass "annotations-idiomatic: each circle sits on its line of code" else report fail "annotations-idiomatic: each circle sits on its line of code" \ @@ -769,8 +775,8 @@ fi # The filter does not visit notes, so a block in a note must not take the # window state of the block before it, here its line chip. -render state-reset typst if command -v pdftotext >/dev/null 2>&1; then + render state-reset typst pdftotext -layout "${work_dir}/state-reset.pdf" "${work_dir}/state-reset.txt" if grep -Eq 'first\.py +2' "${work_dir}/state-reset.txt" && grep -q 'note\.py' "${work_dir}/state-reset.txt" && From fd107145c19184b0fc11c9be5f79e41d9d716e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:30:20 +0100 Subject: [PATCH 11/14] fix: keep one layout cell per executed cell in Typst In a layout of executed cells, each cell and its window state share one Div, so the layout keeps one column per cell. The changelog records the Quarto 1.12 requirement, the test fixture for replaced definitions keeps only that partial, and a test section no longer describes the removed hot-fix. --- CHANGELOG.md | 1 + _extensions/code-window/code-window.lua | 12 +- .../_extensions/fake/_extension.yml | 6 - .../_extensions/fake/biblio.typ | 18 -- .../_extensions/fake/notes.typ | 10 -- .../_extensions/fake/numbering.typ | 23 --- .../ext-definitions/_extensions/fake/page.typ | 34 ---- .../_extensions/fake/template.typ | 28 --- .../_extensions/fake/typst-show.typ | 109 ------------ .../_extensions/fake/typst-template.typ | 160 ------------------ tests/fixtures/layout-cells.qmd | 31 ++++ tests/run.sh | 25 ++- 12 files changed, 60 insertions(+), 397 deletions(-) delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/biblio.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/notes.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/numbering.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/page.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/template.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ delete mode 100644 tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ create mode 100644 tests/fixtures/layout-cells.qmd diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6db8a..be8ba08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - feat: Draw the Typst window around Quarto's own filename helper, so the annotation circles, their links and their text alternatives come from Quarto. - feat: Show Quarto's filename bar on a Typst block with a `filename` that sets `code-window-enabled` to `false`. Before, such a block showed no filename at all. - refactor: The Typst wrapper function no longer takes `annotations`, `bg-colour` or `block-id`. +- build: The extension now needs Quarto 1.12 or newer. ## 1.8.3 (2026-11-12) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index f4234b7..40c2369 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -1026,19 +1026,25 @@ local function frame_typst_blocks(blocks, in_layout) -- Quarto reads the code of an executed cell as the first block of the -- cell, so the window state of that code goes before the cell. The -- layout of a cell places its outputs, so the cell is not a layout here. + -- In a layout, the state and the cell share one Div, so the cell keeps + -- one cell of the layout. local first_state, first_node = frame_typst_code(blk.content[1]) if first_node then local rest = blk.content:clone() rest:remove(1) blk.content = frame_typst_blocks(rest) blk.content:insert(1, first_node) + else + blk.content = frame_typst_blocks(blk.content) + end + if first_state and in_layout then + result:insert(pandoc.Div({ first_state, blk })) + else if first_state then result:insert(first_state) end - else - blk.content = frame_typst_blocks(blk.content) + result:insert(blk) end - result:insert(blk) elseif blk.t == 'Div' then local is_layout = blk.attributes['layout-ncol'] or blk.attributes['layout-nrow'] diff --git a/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml index 3dd92e6..6fff7ca 100644 --- a/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml +++ b/tests/fixtures/ext-definitions/_extensions/fake/_extension.yml @@ -5,10 +5,4 @@ contributes: formats: typst: template-partials: - - numbering.typ - definitions.typ - - typst-template.typ - - page.typ - - typst-show.typ - - notes.typ - - biblio.typ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ b/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ deleted file mode 100644 index 2c8f0eb..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/biblio.typ +++ /dev/null @@ -1,18 +0,0 @@ -$if(citations)$ -$if(csl)$ - -#set bibliography(style: "$csl$") -$elseif(bibliographystyle)$ - -#set bibliography(style: "$bibliographystyle$") -$endif$ -$if(bibliography)$ -$-- Suppress bibliography display when citation-location: margin (consistent with HTML behavior) -$-- Full citations appear in margins; bibliography is loaded but not displayed -$if(suppress-bibliography)$ -#show bibliography: none -$endif$ - -#bibliography(($for(bibliography)$"$bibliography$"$sep$,$endfor$)) -$endif$ -$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/notes.typ b/tests/fixtures/ext-definitions/_extensions/fake/notes.typ deleted file mode 100644 index 99f136c..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/notes.typ +++ /dev/null @@ -1,10 +0,0 @@ -$if(notes)$ -#v(1em) -#block[ -#horizontalrule -#set text(size: .88em) -#v(3pt) // otherwise first note marker is swallowed, bug? - -$notes$ -] -$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ b/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ deleted file mode 100644 index e65286b..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/numbering.typ +++ /dev/null @@ -1,23 +0,0 @@ -// Simple numbering for non-book documents -#let equation-numbering = "(1)" -#let callout-numbering = "1" -#let subfloat-numbering(n-super, subfloat-idx) = { - numbering("1a", n-super, subfloat-idx) -} - -// Theorem configuration for theorion -// Simple numbering for non-book documents (no heading inheritance) -#let theorem-inherited-levels = 0 - -// Theorem numbering format (can be overridden by extensions for appendix support) -// This function returns the numbering pattern to use -#let theorem-numbering(loc) = "1.1" - -// Default theorem render function -#let theorem-render(prefix: none, title: "", full-title: auto, body) = { - if full-title != "" and full-title != auto and full-title != none { - strong[#full-title.] - h(0.5em) - } - body -} diff --git a/tests/fixtures/ext-definitions/_extensions/fake/page.typ b/tests/fixtures/ext-definitions/_extensions/fake/page.typ deleted file mode 100644 index cee094b..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/page.typ +++ /dev/null @@ -1,34 +0,0 @@ -#set page( - paper: $if(papersize)$"$papersize$"$else$"us-letter"$endif$, -$if(margin-geometry)$ - // Margins handled by marginalia.setup below -$elseif(margin)$ - margin: ($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$), -$else$ - margin: (x: 1.25in, y: 1.25in), -$endif$ - numbering: $if(page-numbering)$"$page-numbering$"$else$none$endif$, - columns: $if(columns)$$columns$$else$1$endif$, -) -$if(logo)$ -#set page(background: align($logo.location$, box(inset: $logo.inset$, image("$logo.path$", width: $logo.width$$if(logo.alt)$, alt: "$logo.alt$"$endif$)))) -$endif$ -$if(margin-geometry)$ -// Configure marginalia page geometry (functions defined in definitions.typ) -#show: marginalia.setup.with( - inner: ( - far: $margin-geometry.inner.far$, - width: $margin-geometry.inner.width$, - sep: $margin-geometry.inner.separation$, - ), - outer: ( - far: $margin-geometry.outer.far$, - width: $margin-geometry.outer.width$, - sep: $margin-geometry.outer.separation$, - ), - top: $if(margin.top)$$margin.top$$else$1.25in$endif$, - bottom: $if(margin.bottom)$$margin.bottom$$else$1.25in$endif$, - book: false, - clearance: $margin-geometry.clearance$, -) -$endif$ diff --git a/tests/fixtures/ext-definitions/_extensions/fake/template.typ b/tests/fixtures/ext-definitions/_extensions/fake/template.typ deleted file mode 100644 index 0c9d99e..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/template.typ +++ /dev/null @@ -1,28 +0,0 @@ -$numbering.typ()$ - -$definitions.typ()$ - -$typst-template.typ()$ - -$for(header-includes)$ -$header-includes$ -$endfor$ - -$page.typ()$ - -$typst-show.typ()$ - -$for(include-before)$ -$include-before$ -$endfor$ - -$body$ - -$notes.typ()$ - -$biblio.typ()$ - -$for(include-after)$ -$include-after$ -$endfor$ - diff --git a/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ b/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ deleted file mode 100644 index 72fa761..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/typst-show.typ +++ /dev/null @@ -1,109 +0,0 @@ -#show: doc => article( -$if(title)$ - title: [$title$], -$endif$ -$if(subtitle)$ - subtitle: [$subtitle$], -$endif$ -$if(by-author)$ - authors: ( -$for(by-author)$ -$if(it.name.literal)$ - ( name: [$it.name.literal$], - affiliation: [$for(it.affiliations)$$it.name$$sep$, $endfor$], - email: [$it.email$] ), -$endif$ -$endfor$ - ), -$endif$ -$if(date)$ - date: [$date$], -$endif$ -$if(lang)$ - lang: "$lang$", -$endif$ -$if(region)$ - region: "$region$", -$endif$ -$if(abstract)$ - abstract: [$abstract$], - abstract-title: "$labels.abstract$", -$endif$ -$if(mainfont)$ - font: ("$mainfont$",), -$elseif(brand.typography.base.family)$ - font: $brand.typography.base.family$, -$endif$ -$if(fontsize)$ - fontsize: $fontsize$, -$elseif(brand.typography.base.size)$ - fontsize: $brand.typography.base.size$, -$endif$ -$if(title)$ -$if(brand.typography.headings.family)$ - heading-family: $brand.typography.headings.family$, -$elseif(mainfont)$ - heading-family: ("$mainfont$",), -$endif$ -$if(brand.typography.headings.weight)$ - heading-weight: $brand.typography.headings.weight$, -$endif$ -$if(brand.typography.headings.style)$ - heading-style: "$brand.typography.headings.style$", -$endif$ -$if(brand.typography.headings.color)$ - heading-color: $brand.typography.headings.color$, -$endif$ -$if(brand.typography.headings.line-height)$ - heading-line-height: $brand.typography.headings.line-height$, -$endif$ -$endif$ -$if(section-numbering)$ - sectionnumbering: "$section-numbering$", -$endif$ -$if(mathfont)$ - mathfont: ($for(mathfont)$"$mathfont$",$endfor$), -$endif$ -$if(codefont)$ - codefont: ($for(codefont)$"$codefont$",$endfor$), -$elseif(brand.typography.monospace.family)$ - codefont: $brand.typography.monospace.family$, -$endif$ -$if(linestretch)$ - linestretch: $linestretch$, -$endif$ -$if(thanks)$ - thanks: [$thanks$], -$endif$ -$if(linkcolor)$ - linkcolor: [$linkcolor$], -$endif$ -$if(citecolor)$ - citecolor: [$citecolor$], -$endif$ -$if(filecolor)$ - filecolor: [$filecolor$], -$endif$ -$if(keywords)$ - keywords: ($for(keywords)$"$keywords$",$endfor$), -$endif$ -$if(toc)$ - toc: $toc$, -$endif$ -$if(toc-title)$ - toc_title: [$toc-title$], -$endif$ -$if(toc-indent)$ - toc_indent: $toc-indent$, -$endif$ - toc_depth: $toc-depth$, -$if(lof)$ - lof: true, - lof-title: [$if(crossref.lof-title)$$crossref.lof-title$$else$$quarto.language.crossref-lof-title$$endif$], -$endif$ -$if(lot)$ - lot: true, - lot-title: [$if(crossref.lot-title)$$crossref.lot-title$$else$$quarto.language.crossref-lot-title$$endif$], -$endif$ - doc, -) diff --git a/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ b/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ deleted file mode 100644 index e5ccc37..0000000 --- a/tests/fixtures/ext-definitions/_extensions/fake/typst-template.typ +++ /dev/null @@ -1,160 +0,0 @@ - -#let article( - title: none, - subtitle: none, - authors: none, - keywords: (), - date: none, - abstract-title: none, - abstract: none, - thanks: none, - cols: 1, - lang: "en", - region: "US", - font: none, - fontsize: 11pt, - title-size: 1.5em, - subtitle-size: 1.25em, - heading-family: none, - heading-weight: "bold", - heading-style: "normal", - heading-color: black, - heading-line-height: 0.65em, - mathfont: none, - codefont: none, - linestretch: 1, - sectionnumbering: none, - linkcolor: none, - citecolor: none, - filecolor: none, - toc: false, - toc_title: none, - toc_depth: none, - toc_indent: 1.5em, - lof: false, - lof-title: none, - lot: false, - lot-title: none, - doc, -) = { - // Set document metadata for PDF accessibility - set document(title: title, keywords: keywords) - set document( - author: authors.map(author => content-to-string(author.name)).join(", ", last: " & "), - ) if authors != none and authors != () - set par( - justify: true, - leading: linestretch * 0.65em - ) - set text(lang: lang, - region: region, - size: fontsize) - set text(font: font) if font != none - show math.equation: set text(font: mathfont) if mathfont != none - show raw: set text(font: codefont) if codefont != none - - set heading(numbering: sectionnumbering) - - show link: set text(fill: rgb(content-to-string(linkcolor))) if linkcolor != none - show ref: set text(fill: rgb(content-to-string(citecolor))) if citecolor != none - show link: this => { - if filecolor != none and type(this.dest) == label { - text(this, fill: rgb(content-to-string(filecolor))) - } else { - text(this) - } - } - - let has-title-block = title != none or (authors != none and authors != ()) or date != none or abstract != none - if has-title-block { - place( - top, - float: true, - scope: "parent", - clearance: 4mm, - block(below: 1em, width: 100%)[ - - #if title != none { - align(center, block(inset: 2em)[ - #set par(leading: heading-line-height) if heading-line-height != none - #set text(font: heading-family) if heading-family != none - #set text(weight: heading-weight) - #set text(style: heading-style) if heading-style != "normal" - #set text(fill: heading-color) if heading-color != black - - #text(size: title-size)[#title #if thanks != none { - footnote(thanks, numbering: "*") - counter(footnote).update(n => n - 1) - }] - #(if subtitle != none { - parbreak() - text(size: subtitle-size)[#subtitle] - }) - ]) - } - - #if authors != none and authors != () { - let count = authors.len() - let ncols = calc.min(count, 3) - grid( - columns: (1fr,) * ncols, - row-gutter: 1.5em, - ..authors.map(author => - align(center)[ - #author.name \ - #author.affiliation \ - #author.email - ] - ) - ) - } - - #if date != none { - align(center)[#block(inset: 1em)[ - #date - ]] - } - - #if abstract != none { - block(inset: 2em)[ - #text(weight: "semibold")[#abstract-title] #h(1em) #abstract - ] - } - ] - ) - } - - if toc { - let title = if toc_title == none { - auto - } else { - toc_title - } - block(above: 0em, below: 2em)[ - #outline( - title: title, - depth: toc_depth, - indent: toc_indent - ); - ] - } - - if lof { - block(above: 0em, below: 2em)[ - #outline(title: lof-title, target: figure.where(kind: "quarto-float-fig")); - ] - } - - if lot { - block(above: 0em, below: 2em)[ - #outline(title: lot-title, target: figure.where(kind: "quarto-float-tbl")); - ] - } - - doc -} - -#set table( - inset: 6pt, - stroke: none -) diff --git a/tests/fixtures/layout-cells.qmd b/tests/fixtures/layout-cells.qmd new file mode 100644 index 0000000..c8706f0 --- /dev/null +++ b/tests/fixtures/layout-cells.qmd @@ -0,0 +1,31 @@ +--- +title: "A layout of executed cells" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds two divs in the shape an engine gives executed cells. +Each cell takes one column, so both windows sit side by side. + +::: {layout-ncol="2"} + +::: {.cell} + +```{.python .cell-code} +a = 1 +``` + +::: + +::: {.cell} + +```{.bash .cell-code} +echo b +``` + +::: + +::: diff --git a/tests/run.sh b/tests/run.sh index b3f73b8..ccd1101 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -272,13 +272,13 @@ else fi # ============================================================================ -# The Typst hot-fix stands down with the filter +# The Typst passes stand down with the filter # ============================================================================ -# The hot-fix contributes two passes, and both write Typst that only the +# The framing pass and the inline-code pass both write Typst that only the # filter's own preamble declares. That preamble is not written with the filter -# off, so both passes have to stand down there. The block pass is caught by the -# "_cw-" prefix the colour helpers and the annotation state share. The inline +# off, so both passes have to stand down there. The framing pass is caught by +# the "_cw-" prefix the colour helpers and the window state share. The inline # pass takes a different shape where the theme gives a background colour, and # names no helper at all, so the box itself is the thing to look for. for fixture in filter-disabled filter-disabled-no-language; do @@ -295,10 +295,10 @@ for fixture in filter-disabled filter-disabled-no-language; do fi if grep -q '_cw-' "${work_dir}/${fixture}.typ"; then - report fail "${fixture}: the block pass stands down with the filter" \ + report fail "${fixture}: the framing pass stands down with the filter" \ "no _cw- helper call in ${fixture}.typ" else - report pass "${fixture}: the block pass stands down with the filter" + report pass "${fixture}: the framing pass stands down with the filter" fi if grep -q 'box(fill: rgb(' "${work_dir}/${fixture}.typ"; then @@ -764,6 +764,19 @@ else "one framed block, and no empty #quarto-code-filename(\"python\")[ ] call" fi +# In a layout of executed cells, each cell keeps one column of the layout, so +# the two windows share a line of the page. +if command -v pdftotext >/dev/null 2>&1; then + render layout-cells typst + pdftotext -layout "${work_dir}/layout-cells.pdf" "${work_dir}/layout-cells.txt" + if grep -Eq 'PYTHON.*BASH' "${work_dir}/layout-cells.txt"; then + report pass "layout-cells: each executed cell takes one column" + else + report fail "layout-cells: each executed cell takes one column" \ + "the PYTHON and BASH title bars on the same line of the page" + fi +fi + # In a layout, Quarto still pairs an annotated block with its list. render layout-annotations typst if [ "$(grep -c '^#quarto-annotation-item("' "${work_dir}/layout-annotations.typ" || true)" -eq 1 ]; then From 59fdb200668af0f81b40e760dd7144ddb5959fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:40:58 +0100 Subject: [PATCH 12/14] fix: keep a plain list in its own layout cell in Typst In a layout, only a block with annotation markers takes the list after it into its cell. Every new fixture now renders even without pdftotext, the layout and book tests check that Quarto's filename helper is called, and two comments name the current framing function. --- _extensions/code-window/code-window.lua | 26 +++++++++++++++++++++---- tests/fixtures/layout-list.qmd | 21 ++++++++++++++++++++ tests/run.sh | 22 +++++++++++++++++---- 3 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/layout-list.qmd diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index 40c2369..adb923d 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -785,7 +785,7 @@ end --- This handler runs on every CodeBlock structurally, for every format, --- because a plain `CodeBlock` filter entry is not format-scoped the way --- CONFIG.enabled or CURRENT_FORMAT are. For a Typst render, the same block ---- is already checked once by process_typst_block through the Pandoc filter +--- is already checked once by frame_typst_codeblock through the Pandoc filter --- above it in main.lua's filter list, so checker:attributes is called here --- only in the two branches that do not overlap with that pass: the --- disabled/unconfigured branch (where the Typst pass never runs at all, @@ -795,7 +795,7 @@ end --- CodeBlock-group attribute the Typst path leaves unstripped, such as --- code-window-collapse, which is HTML-only and never read on that path. --- Within the html branch, the check runs before the is_plain_output return, ---- not after: process_typst_block (below) checks every attribute +--- not after: frame_typst_codeblock (below) checks every attribute --- unconditionally, before its own is_unnamed_cell_output test, so an --- output-of-an-executed-cell block is validated on the Typst path even --- though nothing about it is otherwise touched. The html branch matches @@ -991,6 +991,23 @@ local function frame_typst_code(blk) return nil, nil end +--- Whether a code block carries an annotation marker: a line that ends with +--- "", which Quarto reads as a code annotation whatever the comment syntax. +--- @param block pandoc.Block|nil A code block, or Quarto's filename node +--- @return boolean +local function has_annotation_marker(block) + local code = block and (block.t == 'CodeBlock' and block or extract_codeblock(block)) + if not code then + return false + end + for line in (code.text .. '\n'):gmatch('([^\n]*)\n') do + if line:match('<%d+>%s*$') then + return true + end + end + return false +end + --- Walk a list of blocks for Typst and set the window state before each block --- that Quarto draws through its filename helper. The state block goes before --- Quarto's node, at the same level, and nothing goes between a block and the @@ -1009,10 +1026,11 @@ local function frame_typst_blocks(blocks, in_layout) if node and state and in_layout then -- In a layout, the state, the node and the annotation list after them -- share one Div, so the layout keeps one cell for them and Quarto still - -- pairs the block with its list. + -- pairs the block with its list. A list after a block with no + -- annotations keeps its own cell. local cell = { state, node } local next_blk = blocks[i + 1] - if next_blk and next_blk.t == 'OrderedList' then + if next_blk and next_blk.t == 'OrderedList' and has_annotation_marker(blk) then table.insert(cell, next_blk) i = i + 1 end diff --git a/tests/fixtures/layout-list.qmd b/tests/fixtures/layout-list.qmd new file mode 100644 index 0000000..95fafa2 --- /dev/null +++ b/tests/fixtures/layout-list.qmd @@ -0,0 +1,21 @@ +--- +title: "A layout with a block and a list" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +The layout below holds a block with no annotations and a list of its own. +The list takes the second column. + +::: {layout-ncol="2"} + +```{.python} +a = 1 +``` + +1. A list item. + +::: diff --git a/tests/run.sh b/tests/run.sh index ccd1101..124e1bf 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -574,6 +574,7 @@ cp -R "${work_dir}/_extensions" "${book_dir}/_extensions" render book typst book_typ="${book_dir}/index.typ" if [ "$(count_typst_windows "${book_typ}")" -eq 1 ] && + [ "$(grep -c '^#quarto-code-filename(' "${book_typ}" || true)" -eq 1 ] && ! grep -q 'quarto-title-block' "${book_typ}"; then report pass "book: the chapter title block gets no window" else @@ -741,11 +742,12 @@ cp -R "${tests_dir}/fixtures/ext-definitions" "${ext_dir}" cp -R "${work_dir}/_extensions/code-window" "${ext_dir}/_extensions/code-window" render ext-definitions fake-typst if grep -q '#Skylighting(cell-id:' "${ext_dir}/index.typ" && - [ "$(count_typst_windows "${ext_dir}/index.typ")" -eq 1 ]; then + [ "$(count_typst_windows "${ext_dir}/index.typ")" -eq 1 ] && + [ "$(grep -c '^#quarto-code-filename(' "${ext_dir}/index.typ" || true)" -eq 2 ]; then report pass "ext-definitions: the document compiles with the window and the circles" else report fail "ext-definitions: the document compiles with the window and the circles" \ - "one framed block and a #Skylighting(cell-id: call in index.typ" + "one framed block, two #quarto-code-filename( calls and a #Skylighting(cell-id: call in index.typ" fi # ============================================================================ @@ -766,8 +768,8 @@ fi # In a layout of executed cells, each cell keeps one column of the layout, so # the two windows share a line of the page. +render layout-cells typst if command -v pdftotext >/dev/null 2>&1; then - render layout-cells typst pdftotext -layout "${work_dir}/layout-cells.pdf" "${work_dir}/layout-cells.txt" if grep -Eq 'PYTHON.*BASH' "${work_dir}/layout-cells.txt"; then report pass "layout-cells: each executed cell takes one column" @@ -777,6 +779,18 @@ if command -v pdftotext >/dev/null 2>&1; then fi fi +# In a layout, a list after a block with no annotations keeps its own column. +render layout-list typst +if command -v pdftotext >/dev/null 2>&1; then + pdftotext -layout "${work_dir}/layout-list.pdf" "${work_dir}/layout-list.txt" + if grep -Eq '^ {20,}1\. A list item' "${work_dir}/layout-list.txt"; then + report pass "layout-list: a list with no annotations keeps its own column" + else + report fail "layout-list: a list with no annotations keeps its own column" \ + "the list item in the second column of the page" + fi +fi + # In a layout, Quarto still pairs an annotated block with its list. render layout-annotations typst if [ "$(grep -c '^#quarto-annotation-item("' "${work_dir}/layout-annotations.typ" || true)" -eq 1 ]; then @@ -788,8 +802,8 @@ fi # The filter does not visit notes, so a block in a note must not take the # window state of the block before it, here its line chip. +render state-reset typst if command -v pdftotext >/dev/null 2>&1; then - render state-reset typst pdftotext -layout "${work_dir}/state-reset.pdf" "${work_dir}/state-reset.txt" if grep -Eq 'first\.py +2' "${work_dir}/state-reset.txt" && grep -q 'note\.py' "${work_dir}/state-reset.txt" && From 63ab181eecc2f0d3865c78016b5c9fc44593049a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:50:46 +0100 Subject: [PATCH 13/14] fix: declare the Typst window state before any block When a document defines its own window function, the colour helpers and the window state still go at the top, so a framed block or inline code before that definition compiles. Only a line that ends with a space and counts as an annotation marker, so code such as std::vector<1> keeps its list in its own layout cell. --- _extensions/code-window/code-window.lua | 41 +++++++++++++++---------- _extensions/code-window/main.lua | 21 ++++++------- tests/fixtures/layout-list.qmd | 13 ++++++++ tests/fixtures/own-wrapper.qmd | 5 +++ tests/run.sh | 5 +-- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index adb923d..8f85797 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -347,9 +347,11 @@ local TYPST_COLOUR_HELPERS = [==[ --- helper. Each call clears the state it read, so a block the filter never --- visited does not take the state of the block before it. "code-window" is --- renamed with the wrapper option. -local TYPST_FILENAME_HELPER = [==[ +local TYPST_WINDOW_STATE = [==[ #let _cw-window = state("cw-window", none) +]==] +local TYPST_FILENAME_HELPER = [==[ #let _cw-native-filename = quarto-code-filename #let quarto-code-filename(filename, body) = context { @@ -371,15 +373,18 @@ local TYPST_FILENAME_HELPER = [==[ } ]==] ---- Build the complete Typst code-window function definition: the colour ---- helpers, the window function, and the replacement for Quarto's filename ---- helper, in that order. +--- Build the Typst definitions in two parts. The preamble holds the colour +--- helpers and the window state, which inline code and the state blocks use +--- anywhere in the document. The window part holds the window function and +--- the replacement for Quarto's filename helper, which calls it. --- @param with_window boolean Whether to define the window function, which a --- document that defines its own does not need ---- @return string Typst function definition(s) +--- @return string preamble Definitions for the top of the document +--- @return string window Definitions that follow the window function local function build_typst_function_def(with_window) + local preamble = TYPST_COLOUR_HELPERS .. TYPST_WINDOW_STATE if not with_window then - return TYPST_COLOUR_HELPERS .. TYPST_FILENAME_HELPER + return preamble, TYPST_FILENAME_HELPER end local fn_def = string.format([==[ #let code-window( @@ -512,7 +517,7 @@ local function build_typst_function_def(with_window) } ]==]) - return TYPST_COLOUR_HELPERS .. fn_def .. TYPST_FILENAME_HELPER + return preamble, fn_def .. TYPST_FILENAME_HELPER end -- ============================================================================ @@ -991,8 +996,9 @@ local function frame_typst_code(blk) return nil, nil end ---- Whether a code block carries an annotation marker: a line that ends with ---- "", which Quarto reads as a code annotation whatever the comment syntax. +--- Whether a code block carries an annotation marker: a line that ends with a +--- space and "", the shape Quarto reads after a comment opener in any +--- language. Code such as "std::vector<1>" has no space before the marker. --- @param block pandoc.Block|nil A code block, or Quarto's filename node --- @return boolean local function has_annotation_marker(block) @@ -1001,7 +1007,7 @@ local function has_annotation_marker(block) return false end for line in (code.text .. '\n'):gmatch('([^\n]*)\n') do - if line:match('<%d+>%s*$') then + if line:match('%s<%d+>%s*$') then return true end end @@ -1136,10 +1142,10 @@ function Pandoc(doc) -- Set the window state before every block Quarto draws with a filename. doc.blocks = frame_typst_blocks(doc.blocks) - -- A document that defines the window function keeps its own. The state and - -- the filename helper are still needed, as every framed block uses them, - -- and they go after that definition, because Typst only sees the names - -- defined before. + -- A document that defines the window function keeps its own. The filename + -- helper calls it, so the helper goes after that definition, because Typst + -- only sees the names defined before. The colour helpers and the state go at + -- the top, as inline code and every framed block use them. local fn_pattern = '#let ' .. CONFIG.typst_wrapper .. '(' local insert_at = 1 for idx, blk in ipairs(doc.blocks) do @@ -1150,11 +1156,12 @@ function Pandoc(doc) end end - local fn_def = build_typst_function_def(insert_at == 1) + local preamble, window_def = build_typst_function_def(insert_at == 1) if CONFIG.typst_wrapper ~= 'code-window' then - fn_def = fn_def:gsub('code%-window%(', CONFIG.typst_wrapper .. '(') + window_def = window_def:gsub('code%-window%(', CONFIG.typst_wrapper .. '(') end - table.insert(doc.blocks, insert_at, pandoc.RawBlock('typst', fn_def)) + table.insert(doc.blocks, insert_at, pandoc.RawBlock('typst', window_def)) + table.insert(doc.blocks, 1, pandoc.RawBlock('typst', preamble)) return doc end diff --git a/_extensions/code-window/main.lua b/_extensions/code-window/main.lua index 9270ff4..3db6446 100644 --- a/_extensions/code-window/main.lua +++ b/_extensions/code-window/main.lua @@ -107,23 +107,22 @@ end -- Meta runs first because the cell-output pass needs the configuration, and -- that pass runs before the language pass so a marked block is never relabelled. +-- The inline-code box uses the colour helpers that code_window.Pandoc +-- declares, so it runs only where that pass draws chrome. local filters = { { Meta = code_window.Meta }, { Pandoc = mark_cell_output }, { CodeBlock = normalise_language }, { Pandoc = code_window.Pandoc }, { CodeBlock = code_window.CodeBlock }, + { + Pandoc = function(doc) + if not code_window.draws_chrome() then + return nil + end + return inline_code.Pandoc(doc) + end, + }, } --- The inline-code box uses the colour helpers that code_window.Pandoc --- declares, so it runs only where that pass draws chrome. -table.insert(filters, { - Pandoc = function(doc) - if not code_window.draws_chrome() then - return nil - end - return inline_code.Pandoc(doc) - end, -}) - return filters diff --git a/tests/fixtures/layout-list.qmd b/tests/fixtures/layout-list.qmd index 95fafa2..84fc885 100644 --- a/tests/fixtures/layout-list.qmd +++ b/tests/fixtures/layout-list.qmd @@ -19,3 +19,16 @@ a = 1 1. A list item. ::: + +The layout below holds a block whose code ends with `<1>` outside a comment. +Its list takes the second column too. + +::: {layout-ncol="2"} + +```{.cpp} +std::vector<1> +``` + +1. Another item. + +::: diff --git a/tests/fixtures/own-wrapper.qmd b/tests/fixtures/own-wrapper.qmd index b976493..e092b73 100644 --- a/tests/fixtures/own-wrapper.qmd +++ b/tests/fixtures/own-wrapper.qmd @@ -8,6 +8,11 @@ format: --- The document defines the window function itself, so the filter keeps it. +The first block and the `inline code` come before that definition. + +```{.python filename="before.py"} +w = 0 +``` ```{=typst} #let code-window(content, ..args) = content diff --git a/tests/run.sh b/tests/run.sh index 124e1bf..75aa2ae 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -783,11 +783,12 @@ fi render layout-list typst if command -v pdftotext >/dev/null 2>&1; then pdftotext -layout "${work_dir}/layout-list.pdf" "${work_dir}/layout-list.txt" - if grep -Eq '^ {20,}1\. A list item' "${work_dir}/layout-list.txt"; then + if grep -Eq '^ {20,}1\. A list item' "${work_dir}/layout-list.txt" && + grep -Eq '^ {20,}1\. Another item' "${work_dir}/layout-list.txt"; then report pass "layout-list: a list with no annotations keeps its own column" else report fail "layout-list: a list with no annotations keeps its own column" \ - "the list item in the second column of the page" + "both list items in the second column of the page" fi fi From 26de57ceeb8b24a86ca3cf5eadc73592bf32ae82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:50:52 +0100 Subject: [PATCH 14/14] docs: reference the pull request in the changelog --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be8ba08..9b3fe3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## Unreleased -- refactor: Remove the Typst code-annotations and Skylighting hot-fixes and the `hotfix` option, as Quarto 1.12 draws code annotations and filenames in Typst itself. -- feat: Draw the Typst window around Quarto's own filename helper, so the annotation circles, their links and their text alternatives come from Quarto. -- feat: Show Quarto's filename bar on a Typst block with a `filename` that sets `code-window-enabled` to `false`. Before, such a block showed no filename at all. -- refactor: The Typst wrapper function no longer takes `annotations`, `bg-colour` or `block-id`. -- build: The extension now needs Quarto 1.12 or newer. +- refactor: Remove the Typst code-annotations and Skylighting hot-fixes and the `hotfix` option, as Quarto 1.12 draws code annotations and filenames in Typst itself. (#91) +- feat: Draw the Typst window around Quarto's own filename helper, so the annotation circles, their links and their text alternatives come from Quarto. (#91) +- feat: Show Quarto's filename bar on a Typst block with a `filename` that sets `code-window-enabled` to `false`. Before, such a block showed no filename at all. (#91) +- refactor: The Typst wrapper function no longer takes `annotations`, `bg-colour` or `block-id`. (#91) +- build: The extension now needs Quarto 1.12 or newer. (#91) ## 1.8.3 (2026-11-12)