Files
units-quarto-extension/_extensions/units/units.lua
T

357 lines
13 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
-- The true minus sign, U+2212 -- NOT the ASCII hyphen-minus (U+002D) that
-- string.format emits by default, and NOT an en dash (U+2013, reserved for
-- ranges like "10-20 ns"). U+2212 is metrically designed to match the
-- width and vertical centering of "+" (U+002B), so signed values align
-- visually the way they would in typeset mathematics. This is applied
-- only at the final display step -- internal parsing below still matches
-- against the plain ASCII "-" that Lua's own %g/%e formatting produces.
local MINUS = "\u{2212}"
local function displayMinus(s)
return (s:gsub("^%-", MINUS))
end
-- Non-breaking DIGIT-GROUPING separator, per the SI Brochure / NIST SP811 /
-- IUPAC Green Book convention: group digits in threes using a thin space
-- (never a comma or period, since those mean different things -- decimal
-- vs. thousands separator -- depending on locale). U+202F NARROW NO-BREAK
-- SPACE is used rather than U+2009 THIN SPACE because the latter is an
-- ordinary breakable space and could split a number across a line wrap.
local THIN_NBSP = "\u{202F}"
-- group a string of digits (no sign, no decimal point) into threes,
-- e.g. "12345" -> "12" THIN_NBSP "345". Per NIST SP811, grouping is
-- optional for exactly four digits and left ungrouped here; it is
-- effectively mandatory from five digits up, since that's the point at
-- which an ungrouped run of digits becomes hard to parse at a glance.
local function groupDigits(digits)
local len = #digits
if len <= 4 then return digits end
local firstLen = len % 3
if firstLen == 0 then firstLen = 3 end
local groups = { digits:sub(1, firstLen) }
local i = firstLen + 1
while i <= len do
table.insert(groups, digits:sub(i, i + 2))
i = i + 3
end
return table.concat(groups, THIN_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(displayMinus(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(displayMinus(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
-- split into optional sign, integer part, and remainder (decimal point
-- + fractional digits, if any) so grouping applies only to the
-- integer part -- fractional digits are never grouped
local sign, intPart, rest = s:match("^(%-?)(%d+)(.*)$")
if intPart then
s = sign .. groupDigits(intPart) .. rest
end
return { pandoc.Str(displayMinus(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
-- {{< number 12345 >}} -> 12 345 (auto plain/sci, grouped)
-- {{< number 12345 notation=plain >}} -> 12 345 (forced plain, still grouped)
-- {{< number 12345 notation=sci >}} -> 1.23 × 10⁴
-- Bare-number equivalent of the unit shortcodes above, for cases where a
-- value has no associated unit but should still receive the same digit
-- grouping and notation handling.
local function number(args, kwargs)
local value = tonumber(pandoc.utils.stringify(args[1]))
local mode = kwargs and kwargs["notation"] and pandoc.utils.stringify(kwargs["notation"]) or nil
if not value then
io.stderr:write("[units] bad number() arg: '" .. pandoc.utils.stringify(args[1]) .. "'\n")
return pandoc.Str("[number error]")
end
return formatNumber(value, mode)
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,
["number"] = number,
["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,
}