My extension: typst orange book added

This commit is contained in:
2026-03-02 14:42:08 +01:00
parent 68983e30b1
commit 457873a31b
8 changed files with 335 additions and 11 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Posit Software, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
title: Julia-color title: Julia-color
author: Meik Hellmund author: Meik Hellmund
version: 1.0.0 version: 1.0.0
quarto-required: ">=99.9.0" quarto-required: ">=1.9.0"
contributes: contributes:
formats: formats:
common: common:
@@ -19,3 +19,13 @@ contributes:
typst: typst:
include-in-header: include-in-header:
- "resources/typst/juliainc.typ" - "resources/typst/juliainc.typ"
# the following is from
# quarto-cli/src/resources/extension-subtrees/orange-book/_extensions/orange-book
# comment out for an article
template-partials:
- numbering.typ
- page.typ
- typst-show.typ
filters:
- path: orange-book.lua
at: post-quarto

View File

@@ -1,6 +1,8 @@
-- based on -- based on
-- https://github.com/jupyter/nbconvert/blob/main/nbconvert/filters/ansi.py -- https://github.com/jupyter/nbconvert/blob/main/nbconvert/filters/ansi.py
-- debug: pandoc -t native file.{typst,html,..}.md
local ANSI_COLORS = { local ANSI_COLORS = {
"ansi-black", "ansi-black",
"ansi-red", "ansi-red",
@@ -224,18 +226,47 @@ local function HTMLconverter(fg, bg, bold, light, italic, underline, inverse)
return starttag..">","</span>" return starttag..">","</span>"
end end
local function escapeTypstString(s)
s = s:gsub("\\", "\\\\")
s = s:gsub("%$", "\\$")
s = s:gsub("*", "\\*")
s = s:gsub([["]],[[\"]])
return s
end
local function escapeTypstString2(s)
s = s:gsub("@", "\\@")
s = s:gsub("<", "\\<")
s = s:gsub("#", "\\#")
s = s:gsub(">", "\\>")
--s = s:gsub("]","\\]")
s = s:gsub("\n","\\\n")
s = s:gsub("+","\\+")
s = s:gsub("/","\\/")
s = s:gsub("=","\\=")
return s
end
local function noescapeString(s)
return s
end
local function codeBlockTrans(e) local function codeBlockTrans(e)
local converter, fmt local converter, fmt, escapeString
if quarto.doc.isFormat('latex') then if quarto.doc.isFormat('latex') then
converter = LaTeXconverter converter = LaTeXconverter
fmt = 'latex' fmt = 'latex'
escapeString = noescapeString
elseif quarto.doc.isFormat('html') then elseif quarto.doc.isFormat('html') then
converter = HTMLconverter converter = HTMLconverter
fmt = 'html' fmt = 'html'
escapeString = noescapeString
elseif quarto.doc.isFormat('typst') then elseif quarto.doc.isFormat('typst') then
converter = Typstconverter converter = Typstconverter
fmt = 'typst' fmt = 'typst'
escapeString = escapeTypstString
else else
return return
end end
@@ -264,7 +295,7 @@ local function codeBlockTrans(e)
local out="" local out=""
local text = e.text local text = e.text
-- we remove links (eg in julia ParseErrors. THey link to local files, so they are useless anyway) -- we remove links (eg in julia ParseErrors. They link to local files, so they are useless anyway)
text = text:gsub("\x1b%]8;.-\x1b\\", "") text = text:gsub("\x1b%]8;.-\x1b\\", "")
if string.find(text, "\x1b%[") then if string.find(text, "\x1b%[") then
@@ -305,7 +336,7 @@ local function codeBlockTrans(e)
else else
starttag, endtag = converter(fg, bg, bold, light, italic, underline, inverse) starttag, endtag = converter(fg, bg, bold, light, italic, underline, inverse)
end end
out = out .. starttag .. chunk .. endtag out = out .. starttag .. escapeString(chunk) .. endtag
end end
while next(numbers) ~= nil do while next(numbers) ~= nil do
@@ -359,7 +390,7 @@ local function codeBlockTrans(e)
end end
end end
else else
out = text out = escapeString(text)
end end
if fmt == 'html' then if fmt == 'html' then
return pandoc.RawBlock(fmt, return pandoc.RawBlock(fmt,
@@ -369,8 +400,12 @@ local function codeBlockTrans(e)
return pandoc.RawBlock(fmt, [[\begin{]]..texenv.."}\n"..out.."\n"..[[\end{]].. texenv .. "}") return pandoc.RawBlock(fmt, [[\begin{]]..texenv.."}\n"..out.."\n"..[[\end{]].. texenv .. "}")
end end
if fmt == 'typst' then if fmt == 'typst' then
if texenv == "OutputCell" then
return pandoc.RawBlock(fmt, "#"..texenv.."[\n" .. escapeTypstString2(out) .. "\n]")
else
return pandoc.RawBlock(fmt, "#"..texenv.."[\n" .. out .. "\n]") return pandoc.RawBlock(fmt, "#"..texenv.."[\n" .. out .. "\n]")
end end
end
end end
@@ -398,12 +433,52 @@ local function divCodeBlockNoHeader1(e)
if el.t == 'Header' then if el.t == 'Header' then
el.level = 6 el.level = 6
end end
-- if el.t == 'CodeBlock' then -- attempt for typst, but we use an extra quarto cell
-- for i,v in ipairs(el.classes) do
-- if v=="julia" or v=="jldoctest" then -- example code and before
-- el.classes:remove(i)
-- end
-- end
-- end
end end
return e return e
end end
-- test if two divs should be merged
local function testmerge(d1, d2)
return d1 and d1.t == "Div" and d1.classes:includes("cell-output") and #d1.content == 1
and d2 and d2.t == "Div" and d2.classes:includes("cell-output") and #d2.content == 1
and d1.content[1].t == "CodeBlock" and not d1.classes:includes("cell-output-stderr")
and d2.content[1].t == "CodeBlock" and not d2.classes:includes("cell-output-stderr")
end
-- merge (div (codecell (text1)), div (codecell(text2))) to div(codecell(text1+text2))
local function blockMerge(es)
local nl = ""
for i = #es-1, 1, -1 do
if testmerge(es[i], es[i+1]) then
str1 = es[i].content[1].text
str2 = es[i+1].content[1].text
nl = "\n"
if es[i].classes:includes("cell-output-stdout") and es[i+1].classes:includes("cell-output-stdout") then
if str1:sub(-1) == "\n" then
nl = ""
end
if str2:sub(1, 1) == "\n" then
nl = ""
end
end
es[i].content[1].text = str1 .. nl .. str2
es:remove(i+1)
end
end
return es
end
return { return {
{Div = divStderr}, {Div = divStderr},
{Div = divCodeBlockNoHeader1}, {Div = divCodeBlockNoHeader1},
{Blocks = blockMerge},
{CodeBlock = codeBlockTrans}, {CodeBlock = codeBlockTrans},
} }

View File

@@ -0,0 +1,38 @@
// Chapter-based numbering for books with appendix support
#let equation-numbering = it => {
let pattern = if state("appendix-state", none).get() != none { "(A.1)" } else { "(1.1)" }
numbering(pattern, counter(heading).get().first(), it)
}
#let callout-numbering = it => {
let pattern = if state("appendix-state", none).get() != none { "A.1" } else { "1.1" }
numbering(pattern, counter(heading).get().first(), it)
}
#let subfloat-numbering(n-super, subfloat-idx) = {
let chapter = counter(heading).get().first()
let pattern = if state("appendix-state", none).get() != none { "A.1a" } else { "1.1a" }
numbering(pattern, chapter, n-super, subfloat-idx)
}
// Theorem configuration for theorion
// Chapter-based numbering (H1 = chapters)
#let theorem-inherited-levels = 1
// Appendix-aware theorem numbering
#let theorem-numbering(loc) = {
if state("appendix-state", none).at(loc) != none { "A.1" } else { "1.1" }
}
// Theorem render function
// Note: brand-color is not available at this point in template processing
#let theorem-render(prefix: none, title: "", full-title: auto, body) = {
block(
width: 100%,
inset: (left: 1em),
stroke: (left: 2pt + black),
)[
#if full-title != "" and full-title != auto and full-title != none {
strong[#full-title]
linebreak()
}
#body
]
}

View File

@@ -0,0 +1,69 @@
-- orange-book.lua
-- Orange-book specific part and appendix handling for Typst books
local function is_typst_book()
local file_state = quarto.doc.file_metadata()
return quarto.doc.is_format("typst") and
file_state ~= nil and
file_state.file ~= nil
end
local header_filter = {
Header = function(el)
local file_state = quarto.doc.file_metadata()
if not is_typst_book() then
return nil
end
if file_state == nil or file_state.file == nil then
return nil
end
local file = file_state.file
local bookItemType = file.bookItemType
if el.level ~= 1 or bookItemType == nil then
return nil
end
-- Handle parts
if bookItemType == "part" then
return pandoc.RawBlock('typst', '#part[' .. pandoc.utils.stringify(el.content) .. ']')
end
-- Handle appendices
if bookItemType == "appendix" then
-- First appendix triggers the show rule with localized "Appendices" title
if file.bookItemNumber == 1 or file.bookItemNumber == nil then
-- Get localized title from language settings
local language = quarto.doc.language
local appendicesTitle = (language and language["section-title-appendices"]) or "Appendices"
-- Use hide-parent: true to work around orange-book bug where unnumbered headings
-- (like Bibliography) trigger duplicate "Appendices" TOC entries.
local appendixStart = pandoc.RawBlock('typst',
'#show: appendices.with("' .. appendicesTitle .. '", hide-parent: true)')
-- If this is the synthetic "Appendices" divider heading (has .unnumbered class),
-- emit our own Appendices heading for TOC display
if el.classes:includes("unnumbered") then
local appendicesHeading = pandoc.RawBlock('typst',
'#heading(level: 1, numbering: none)[' .. appendicesTitle .. ']')
return {appendixStart, appendicesHeading}
end
return {appendixStart, el}
end
end
return nil
end
}
-- Combine with file_metadata_filter so book metadata markers are parsed
-- during this filter's document traversal (needed for bookItemType, etc.)
return quarto.utils.combineFilters({
quarto.utils.file_metadata_filter(),
header_filter
})

View File

@@ -0,0 +1,15 @@
#set page(
paper: $if(papersize)$"$papersize$"$else$"us-letter"$endif$,
$if(margin-geometry)$
// Margins handled by marginalia.setup in typst-show.typ AFTER book.with()
$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$,
)
// Logo is handled by orange-book's cover page, not as a page background
// NOTE: marginalia.setup is called in typst-show.typ AFTER book.with()
// to ensure marginalia's margins override the book format's default margins

View File

@@ -2,10 +2,45 @@
#show raw: set text(font: "JuliaMono") #show raw: set text(font: "JuliaMono")
// define cell layout // define cell layout
#let OutputCell = block.with(width:100%, inset: 5pt)
#let AnsiOutputCell = block.with(width: 100%, inset: 5pt) //#let EndLine() = raw("\n")
//#let OutputCell(lines) = {
// let blocks = []
// for ln in lines {
// blocks = blocks + ln + EndLine()
// }
// block(width:100%, inset:(x:5pt, y:0pt), stroke:( left: 2pt + gray), blocks)
//}
#let OutputCell(content) = block(
width: 100%,
inset: (x: 5pt, y: 5pt),
above: 0pt,
below: 8pt,
stroke: (left: 2pt + gray, bottom: .5pt + gray)
)[
#set text(font: "JuliaMono", size:8pt)
#content
]
#let AnsiOutputCell(content) = block(
width: 100%,
inset: (x: 5pt, y: 5pt),
above: 0pt,
below: 8pt,
stroke: (left: 2pt + gray, bottom: .5pt + gray)
)[
#set text(font: "JuliaMono", size:9pt)
#content
]
// does not exist with julia engine
#let StderrOutputCell = block.with(width: 100%, stroke: 1pt + red, inset: 5pt) #let StderrOutputCell = block.with(width: 100%, stroke: 1pt + red, inset: 5pt)
//#set highlight(top-edge: "ascender", bottom-edge: "descender") //#set highlight(top-edge: "ascender", bottom-edge: "descender")
#let invertbox(color, c) = box(outset: (x: 0.05em, y: 0.25em), fill: color, c) #let invertbox(color, c) = box(outset: (x: 0.05em, y: 0.25em), fill: color, c)

View File

@@ -0,0 +1,61 @@
#import "@preview/orange-book:0.7.1": book, part, chapter, appendices
#show: book.with(
$if(title)$
title: [$title$],
$endif$
$if(subtitle)$
subtitle: [$subtitle$],
$endif$
$if(by-author)$
author: "$for(by-author)$$it.name.literal$$sep$, $endfor$",
$endif$
$if(date)$
date: "$date$",
$endif$
$if(lang)$
lang: "$lang$",
$endif$
main-color: brand-color.at("primary", default: blue),
logo: {
let logo-info = brand-logo.at("medium", default: none)
if logo-info != none { image(logo-info.path, alt: logo-info.at("alt", default: none)) }
},
$if(toc-depth)$
outline-depth: $toc-depth$,
$endif$
$if(lof)$
list-of-figure-title: "$if(crossref.lof-title)$$crossref.lof-title$$else$$crossref-lof-title$$endif$",
$endif$
$if(lot)$
list-of-table-title: "$if(crossref.lot-title)$$crossref.lot-title$$else$$crossref-lot-title$$endif$",
$endif$
$if(margin-geometry)$
padded-heading-number: false,
$endif$
)
$if(margin-geometry)$
// Configure marginalia page geometry for book context
// Geometry computed by Quarto's meta.lua filter (typstGeometryFromPaperWidth)
// IMPORTANT: This must come AFTER book.with() to override the book format's margin settings
#import "@preview/marginalia:0.3.1" as marginalia
#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$,
// CRITICAL: Enable book mode for recto/verso awareness
book: true,
clearance: $margin-geometry.clearance$,
)
$endif$