Files
units-quarto-extension/_extensions/units/units.lua
T
2026-07-07 00:18:15 -04:00

294 lines
10 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
local NBSP = "\u{00A0}"
local function nbsp() return pandoc.Str(NBSP) end
-- render a value already known to be in scientific-notation range
-- returns a list of inlines: mantissa <NBSP> × <NBSP> 10^exp^
local function formatSci(value)
local s = string.format("%.3e", value) -- e.g. "6.020e+23" or "1.500e-06"
local mantissa, sign, exp = s:match("^(-?%d+%.?%d*)e([+-])(%d+)$")
if not mantissa then
-- fallback, shouldn't happen with %.3e, but never crash on bad input
return { pandoc.Str(s) }
end
-- trim trailing zeros (and a trailing bare decimal point) from mantissa
mantissa = mantissa:gsub("0+$", ""):gsub("%.$", "")
local expNum = tonumber(sign .. exp) -- keeps sign, drops leading zeros (e.g. "+23" -> 23, "-06" -> -6)
local out = { pandoc.Str(mantissa) }
if expNum ~= 0 then
table.insert(out, nbsp())
table.insert(out, pandoc.Str("×"))
table.insert(out, nbsp())
table.insert(out, pandoc.Str("10"))
table.insert(out, pandoc.Superscript({ pandoc.Str(tostring(expNum)) }))
end
return out
end
-- render a value in plain decimal notation, 3 significant figures.
-- IMPORTANT: C's %g spec has its own internal rule for switching to
-- exponential form ("use %e if exponent < -4 or exponent >= precision").
-- With precision 3, that means %.3g silently flips to "1e+03"-style
-- output for anything >= 1000 -- BEFORE any magnitude threshold of ours
-- gets a say. Rather than duplicating that rule with a second threshold
-- we let %g decide, then intercept its exponential output and re-render it through
-- formatSci() instead of ever emitting the raw "1e+03" string.
local function formatPlain(value)
local s = string.format("%.3g", value)
if s:find("[eE]") then
return formatSci(value)
end
return { pandoc.Str(s) }
end
-- decide plain vs. scientific, unless overridden.
-- mode: nil/"auto" (let %g decide, see formatPlain), "sci", or "plain"
local function formatNumber(value, mode)
if value == 0 then return { pandoc.Str("0") } end
if mode == "sci" then return formatSci(value) end
return formatPlain(value) -- handles both "auto" and explicit "plain"
end
local function appendAll(dst, list)
for _, inl in ipairs(list) do table.insert(dst, inl) end
end
local ENERGY_UNITS = {
["J"] = { md = "J", per_base = 1 },
["kJ"] = { md = "kJ", per_base = 1e-3 },
["cal"] = { md = "cal", per_base = 1 / 4.184 },
["kcal"] = { md = "kcal", per_base = 1 / 4184 },
}
local MOLAR_ENERGY_UNITS = {
["J/mol"] = { md = "J·mol^-1^", per_base = 1000 },
["kJ/mol"] = { md = "kJ·mol^-1^", per_base = 1 },
["cal/mol"] = { md = "cal·mol^-1^", per_base = 1000 / 4.184 },
["kcal/mol"] = { md = "kcal·mol^-1^", per_base = 1 / 4.184 },
["eV"] = { md = "eV", per_base = 1 / 96.485 },
["hartree"] = { md = "*E*~h~", per_base = 1 / 2625.5 },
["cm-1"] = { md = "cm^-1^", per_base = 1 / 0.0119627 },
}
local DISTANCE_UNITS = {
["angstrom"] = { md = "Å", per_base = 1 },
["nanometer"] = { md = "nm", per_base = 0.1 },
["picometer"] = { md = "pm", per_base = 100 },
["bohr"] = { md = "*a*~0~", per_base = 1.8897259886 },
["meter"] = { md = "m", per_base = 1e-10 },
}
local TIME_UNITS = {
["second"] = { md = "s", per_base = 1 },
["millisecond"] = { md = "ms", per_base = 1e3 },
["microsecond"] = { md = "μs", per_base = 1e6 },
["nanosecond"] = { md = "ns", per_base = 1e9 },
["picosecond"] = { md = "ps", per_base = 1e12 },
["femtosecond"] = { md = "fs", per_base = 1e15 },
["minute"] = { md = "min", per_base = 1 / 60 },
["hour"] = { md = "h", per_base = 1 / 3600 },
["day"] = { md = "d", per_base = 1 / 86400 },
}
local CONCENTRATION_UNITS = {
["molar"] = { md = "M", per_base = 1 },
["millimolar"] = { md = "mM", per_base = 1e3 },
["micromolar"] = { md = "μM", per_base = 1e6 },
["nanomolar"] = { md = "nM", per_base = 1e9 },
["picomolar"] = { md = "pM", per_base = 1e12 },
["femtomolar"] = { md = "fM", per_base = 1e15 },
}
local VOLTAGE_UNITS = {
["kilovolt"] = { md = "kV", per_base = 1e-3 },
["volt"] = { md = "V", per_base = 1 },
["millivolt"] = { md = "mV", per_base = 1e3 },
["microvolt"] = { md = "μV", per_base = 1e6 },
}
local TEMPERATURE_UNITS = {
["kelvin"] = {
md = "K",
toKelvin = function(v) return v end,
fromKelvin = function(k) return k end,
},
["celsius"] = {
md = "°C",
toKelvin = function(v) return v + 273.15 end,
fromKelvin = function(k) return k - 273.15 end,
},
["fahrenheit"] = {
md = "°F",
toKelvin = function(v) return (v + 459.67) * 5 / 9 end,
fromKelvin = function(k) return k * 9 / 5 - 459.67 end,
},
}
local MASS_CONC_UNITS = {
["mg/mL"] = { md = "mg·mL^-1^", per_base = 1 },
["μg/mL"] = { md = "μg·mL^-1^", per_base = 1e3 },
["ng/mL"] = { md = "ng·mL^-1^", per_base = 1e6 },
}
local SYMBOLS = {}
for _, tbl in ipairs({
ENERGY_UNITS, MOLAR_ENERGY_UNITS, DISTANCE_UNITS,
TIME_UNITS, CONCENTRATION_UNITS, VOLTAGE_UNITS, TEMPERATURE_UNITS,
}) do
for k, v in pairs(tbl) do SYMBOLS[k] = v end
end
local function md_to_inlines(md)
return pandoc.read(md, "markdown").blocks[1].content
end
-- {{< unit nanomolar >}} -> just the formatted symbol, any category
local function unit(args)
local key = pandoc.utils.stringify(args[1])
local u = SYMBOLS[key]
if not u then
io.stderr:write("[units] unknown unit '" .. key .. "'\n")
return pandoc.Str(key)
end
return md_to_inlines(u.md)
end
-- {{< sci 6.02e23 >}} -> 6.02 × 10²³ (always scientific, no unit)
local function sci(args)
local value = tonumber(pandoc.utils.stringify(args[1]))
if not value then
io.stderr:write("[units] bad sci() arg: '" .. pandoc.utils.stringify(args[1]) .. "'\n")
return pandoc.Str("[sci error]")
end
return formatSci(value)
end
-- generic converter, parameterized by which table to use.
-- optional kwargs.notation = "sci" | "plain" forces that format
-- for both the source value and (if present) the converted value.
local function makeQuantityShortcode(TABLE, label)
return function(args, kwargs)
local value = tonumber(pandoc.utils.stringify(args[1]))
local fromKey = pandoc.utils.stringify(args[2])
local toKey = args[3] and pandoc.utils.stringify(args[3]) or nil
local from = TABLE[fromKey]
local mode = kwargs and kwargs["notation"] and pandoc.utils.stringify(kwargs["notation"]) or nil
if not value or not from then
io.stderr:write("[units] bad " .. label .. "() args, unit='" ..
tostring(fromKey) .. "'\n")
return pandoc.Str("[" .. label .. " error]")
end
local out = {}
appendAll(out, formatNumber(value, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(from.md))
if toKey then
local to = TABLE[toKey]
if to then
local baseVal = value / from.per_base
local converted = baseVal * to.per_base
table.insert(out, nbsp())
table.insert(out, pandoc.Str("("))
appendAll(out, formatNumber(converted, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(to.md))
table.insert(out, pandoc.Str(")"))
else
io.stderr:write("[units] unknown target unit '" .. toKey .. "' for " .. label .. "\n")
end
end
return out
end
end
-- {{< temperature 25 celsius kelvin >}}
-- Uses toKelvin/fromKelvin round-trip instead of a per_base ratio,
-- since C/F/K aren't related by a pure multiplicative factor.
local function temperature(args, kwargs)
local value = tonumber(pandoc.utils.stringify(args[1]))
local fromKey = pandoc.utils.stringify(args[2])
local toKey = args[3] and pandoc.utils.stringify(args[3]) or nil
local from = TEMPERATURE_UNITS[fromKey]
local mode = kwargs and kwargs["notation"] and pandoc.utils.stringify(kwargs["notation"]) or nil
if not value or not from then
io.stderr:write("[units] bad temperature() args, unit='" .. tostring(fromKey) .. "'\n")
return pandoc.Str("[temperature error]")
end
local kelvin = from.toKelvin(value)
if kelvin < 0 then
io.stderr:write("[units] warning: temperature() computed " ..
string.format("%.2f", kelvin) .. " K, below absolute zero -- check input\n")
end
local out = {}
appendAll(out, formatNumber(value, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(from.md))
if toKey then
local to = TEMPERATURE_UNITS[toKey]
if to then
local converted = to.fromKelvin(kelvin)
table.insert(out, nbsp())
table.insert(out, pandoc.Str("("))
appendAll(out, formatNumber(converted, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(to.md))
table.insert(out, pandoc.Str(")"))
else
io.stderr:write("[units] unknown target unit '" .. toKey .. "' for temperature\n")
end
end
return out
end
-- {{< massconc 0.5 mg/mL 350.4 micromolar >}}
local function massconc(args, kwargs)
local value = tonumber(pandoc.utils.stringify(args[1]))
local fromKey = pandoc.utils.stringify(args[2])
local mw = tonumber(pandoc.utils.stringify(args[3]))
local toKey = pandoc.utils.stringify(args[4])
local from = MASS_CONC_UNITS[fromKey]
local to = CONCENTRATION_UNITS[toKey]
local mode = kwargs and kwargs["notation"] and pandoc.utils.stringify(kwargs["notation"]) or nil
if not (value and from and mw and to) then
io.stderr:write("[units] bad massconc() args\n")
return pandoc.Str("[massconc error]")
end
local gPerL = value / from.per_base
local molPerL = gPerL / mw
local converted = molPerL * to.per_base
local out = {}
appendAll(out, formatNumber(value, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(from.md))
table.insert(out, nbsp())
table.insert(out, pandoc.Str("("))
appendAll(out, formatNumber(converted, mode))
table.insert(out, nbsp())
appendAll(out, md_to_inlines(to.md))
table.insert(out, pandoc.Str(", MW " .. tostring(mw) .. NBSP .. "g/mol)"))
return out
end
return {
["unit"] = unit,
["sci"] = sci,
["energy"] = makeQuantityShortcode(ENERGY_UNITS, "energy"),
["molarenergy"] = makeQuantityShortcode(MOLAR_ENERGY_UNITS, "molarenergy"),
["distance"] = makeQuantityShortcode(DISTANCE_UNITS, "distance"),
["time"] = makeQuantityShortcode(TIME_UNITS, "time"),
["concentration"] = makeQuantityShortcode(CONCENTRATION_UNITS, "concentration"),
["voltage"] = makeQuantityShortcode(VOLTAGE_UNITS, "voltage"),
["temperature"] = temperature,
["massconc"] = massconc,
}