357 lines
13 KiB
Lua
357 lines
13 KiB
Lua
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,
|
||
}
|