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 × 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 local function finalizePlainString(s) local sign, intPart, rest = s:match("^(%-?)(%d+)(.*)$") if intPart then s = sign .. groupDigits(intPart) .. rest end return displayMinus(s) 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. -- NOTE: this function is used for the "auto" mode ONLY. It is allowed to -- fall back to scientific notation. For an explicit, non-negotiable -- notation="plain" override, see formatForcedPlain below instead. local function formatPlain(value) local s = string.format("%.3g", value) if s:find("[eE]") then return formatSci(value) end return { pandoc.Str(finalizePlainString(s)) } end -- render a value in plain decimal notation that NEVER falls back to -- scientific form, regardless of magnitude, this is what -- notation="plain" actually promises the caller. %g's own exponent-switch -- rule (the reason formatPlain above sometimes defers to formatSci) is -- sidestepped entirely by computing the required number of decimal places -- directly from the value's exponent (read robustly off a %e string, -- since %e always reports the exponent Lua/C computed rather than -- re-deriving it ourselves and risking a boundary rounding mismatch) and -- formatting with %f at that precision. local function formatForcedPlain(value, sig) sig = sig or 3 if value == 0 then return { pandoc.Str("0") } end local sciStr = string.format("%." .. (sig - 1) .. "e", value) local exp = tonumber(sciStr:match("e([+-]?%d+)$")) local decimals = sig - 1 - exp if decimals < 0 then decimals = 0 end local s = string.format("%." .. decimals .. "f", value) if s:find("%.") then s = s:gsub("0+$", ""):gsub("%.$", "") end return { pandoc.Str(finalizePlainString(s)) } end -- decide plain vs. scientific, unless overridden. -- mode: nil/"auto" (let %g decide, see formatPlain), "sci", or "plain" -- "plain" is a hard override -- it must never silently become scientific, -- which is exactly the bug that motivated splitting formatForcedPlain out -- from formatPlain rather than reusing it for both cases. local function formatNumber(value, mode) if value == 0 then return { pandoc.Str("0") } end if mode == "sci" then return formatSci(value) end if mode == "plain" then return formatForcedPlain(value) end return formatPlain(value) -- "auto" 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, }