From 221e5c31cbaf343ceeee70651dd106869d2d1557 Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Tue, 7 Jul 2026 00:18:15 -0400 Subject: [PATCH] feat: initial (slop) commit --- README.md | 235 +++++++++++++++++++++++++ _extensions/units/_extension.yml | 8 + _extensions/units/units.lua | 293 +++++++++++++++++++++++++++++++ 3 files changed, 536 insertions(+) create mode 100644 README.md create mode 100644 _extensions/units/_extension.yml create mode 100644 _extensions/units/units.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..725cfae --- /dev/null +++ b/README.md @@ -0,0 +1,235 @@ +# `units`: A Quarto Extension for Standardized Scientific Unit Notation + + +This extension provides a centralized, extensible mechanism for the consistent rendering of physical quantities across Quarto-based scientific websites and publications. +It is designed principally for use in computational chemistry, biochemistry, and computer-aided drug design (CADD), disciplines in which a single document routinely reports energies, distances, timescales, concentrations, electrostatic potentials, and temperatures spanning many orders of magnitude and multiple unit systems. + +## Motivation + +The correct typographic and semantic treatment of physical quantities is governed by well-established conventions. +Most notably those of the International Bureau of Weights and Measures (BIPM) as codified in the SI Brochure, and their discipline-specific elaborations in the IUPAC *Quantities, Units and Symbols in Physical Chemistry* ("Green Book") and NIST Special Publication 811. +These conventions specify, among other things: upright (non-italic) typesetting of unit symbols; a non-breaking space between a numerical value and its associated unit; the use of negative exponents rather than stacked solidi for compound units; and unambiguous conversion factors between coherent and non-coherent units within a given physical dimension. + +In practice, manuscripts and websites authored under time constraints tend to depart from these conventions inconsistently: for example, a bond length may be rendered as `1.54 Å` in one section and `1.54Å` (with an ordinary, breakable space) in another or an energy value may be transcribed from kJ·mol⁻¹ to kcal·mol⁻¹ by hand. + +## Installation + +You can install this Quarto extension with the following command: + + +```bash +quarto add https://git.scient.ing/infra/units-quarto-extension/archive/main.zip +``` + +Following installation, confirm the presence of the extension within the project: + +```text +_extensions/ +└── units/ + ├── _extension.yml + └── units.lua +``` + +A minimal document using the extension should render without error: + +```markdown +--- +title: "Installation Check" +--- + +The bond length is {{< distance 1.54 angstrom >}}. +``` + +## Reference: Shortcodes + +| Shortcode | Signature | Description | +|---|---|---| +| `unit` | `{{< unit >}}` | Renders the typeset symbol for a single unit, with no numerical value or conversion. | +| `sci` | `{{< sci >}}` | Renders a bare numerical value in scientific notation, independent of any unit. | +| `energy` | `{{< energy [] >}}` | Bulk (non-molar) energy: J, kJ, cal, kcal. | +| `molarenergy` | `{{< molarenergy [] >}}` | Molar energy: J/mol, kJ/mol, cal/mol, kcal/mol, eV, hartree, cm⁻¹. | +| `distance` | `{{< distance [] >}}` | Distance: angstrom, nanometer, picometer, bohr, meter. | +| `time` | `{{< time [] >}}` | Time: second and its SI-prefixed subdivisions, plus minute, hour, day. | +| `concentration` | `{{< concentration [] >}}` | Molar concentration: M, mM, μM, nM, pM, fM. | +| `voltage` | `{{< voltage [] >}}` | Electrical potential: kV, V, mV, μV. | +| `temperature` | `{{< temperature [] >}}` | Temperature: kelvin, celsius, fahrenheit (affine conversion). | +| `massconc` | `{{< massconc >}}` | Mass-per-volume concentration converted to molar concentration given an explicit molar mass. | + +Each quantity shortcode accepts an optional keyword argument, `notation=sci` or `notation=plain`, which overrides the automatic determination of numerical formatting described in Section 6. + +## Supported Unit Categories + +### Bulk Energy (base unit: joule) + +| Unit key | Symbol | Notes | +|---|---|---| +| `J` | J | SI base representation | +| `kJ` | kJ | | +| `cal` | cal | Thermochemical calorie (1 cal = 4.184 J) | +| `kcal` | kcal | | + +### Molar Energy (base unit: kJ·mol⁻¹) + +| Unit key | Symbol | Notes | +|---|---|---| +| `J/mol` | J·mol⁻¹ | | +| `kJ/mol` | kJ·mol⁻¹ | | +| `cal/mol` | cal·mol⁻¹ | | +| `kcal/mol` | kcal·mol⁻¹ | | +| `eV` | eV | 1 eV = 96.485 kJ·mol⁻¹ | +| `hartree` | *E*ₕ | Atomic unit of energy; 1 hartree = 2625.5 kJ·mol⁻¹ | +| `cm-1` | cm⁻¹ | Wavenumber, as conventionally reported in vibrational spectroscopy | + +### Distance (base unit: ångström) + +| Unit key | Symbol | Notes | +|---|---|---| +| `angstrom` | Å | Rendered using U+00C5, the canonical (NFKC-normalized) form | +| `nanometer` | nm | | +| `picometer` | pm | | +| `bohr` | *a*₀ | Atomic unit of length | +| `meter` | m | | + +### Time (base unit: second) + +| Unit key | Symbol | +|---|---| +| `second` | s | +| `millisecond` | ms | +| `microsecond` | μs | +| `nanosecond` | ns | +| `picosecond` | ps | +| `femtosecond` | fs | +| `minute` | min | +| `hour` | h | +| `day` | d | + +### Molar Concentration (base unit: molar) + +| Unit key | Symbol | +|---|---| +| `molar` | M | +| `millimolar` | mM | +| `micromolar` | μM | +| `nanomolar` | nM | +| `picomolar` | pM | +| `femtomolar` | fM | + +### Mass Concentration (base unit: mg·mL⁻¹) + +| Unit key | Symbol | +|---|---| +| `mg/mL` | mg·mL⁻¹ | +| `μg/mL` | μg·mL⁻¹ | +| `ng/mL` | ng·mL⁻¹ | + +This category is consumed exclusively by the `massconc` shortcode, which additionally requires a molar mass argument to compute the corresponding molar concentration. + +### Voltage (base unit: volt) + +| Unit key | Symbol | +|---|---| +| `kilovolt` | kV | +| `volt` | V | +| `millivolt` | mV | +| `microvolt` | μV | + +### Temperature + +| Unit key | Symbol | Conversion basis | +|---|---|---| +| `kelvin` | K | Identity (SI base unit; no degree sign, per convention) | +| `celsius` | °C | *T*(K) = *T*(°C) + 273.15 | +| `fahrenheit` | °F | *T*(K) = [*T*(°F) + 459.67] × 5/9 | + +## Numerical Formatting + +The rendered representation of a numerical value is determined automatically unless overridden by the `notation` keyword argument. + +By default, a value is formatted to three significant figures using fixed decimal notation. +Should this formatting internally require exponential notation, the extension instead renders the value in explicit scientific notation, with the mantissa and a true superscript exponent joined by a non-breaking multiplication sign: + +```markdown +{{< sci 6.02214076e23 >}} +``` + +renders as 6.022 × 10²³, rather than the unprocessed string `6.02e+23`. + +## Worked Examples + +### Distance + +```markdown +The O–H bond length is {{< distance 0.96 angstrom >}}. +Converted to nanometers: {{< distance 1.54 angstrom nanometer >}}. +``` +> The O–H bond length is 0.96 Å. +> Converted to nanometers: 1.54 Å (0.154 nm). + +### Bulk Energy + +```markdown +Combustion releases {{< energy 4.18 J >}} of heat under these conditions. +``` +> Combustion releases 4.18 J of heat under these conditions. + +### Molar Energy + +```markdown +The activation barrier was {{< molarenergy 45.2 kJ/mol kcal/mol >}}. +A single-point DFT energy of {{< molarenergy 0.0421 hartree kcal/mol >}} was obtained. +``` +> The activation barrier was 45.2 kJ·mol⁻¹ (10.8 kcal·mol⁻¹). +> A single-point DFT energy of 0.0421 *E*ₕ (110 kcal·mol⁻¹) was obtained. + +### Time + +```markdown +The production molecular dynamics simulation was extended to +{{< time 500 nanosecond microsecond >}}, using an integration timestep of +{{< time 2e-15 second femtosecond >}}. +``` +> The production molecular dynamics simulation was extended to 500 ns +> (0.5 μs), using an integration timestep of 2 × 10⁻¹⁵ s (2 fs). + +### Molar Concentration + +```markdown +The compound exhibited a Kᵢ of {{< concentration 12 nanomolar >}} +against the target enzyme. +``` +> The compound exhibited a Kᵢ of 12 nM against the target enzyme. + +### Voltage + +```markdown +Resting membrane potential was recorded at {{< voltage -70 millivolt >}}. +``` +> Resting membrane potential was recorded at -70 mV. + +### Temperature + +```markdown +Assays were conducted at {{< temperature 25 celsius kelvin >}}, approximating +physiological temperature of {{< temperature 310.15 kelvin celsius >}}. +``` +> Assays were conducted at 25 °C (298 K), approximating physiological +> temperature of 310.15 K (37 °C). + +### Scientific Notation and Rate Constants + +```markdown +The observed second-order rate constant was +{{< concentration 2.1e6 molar notation=sci >}}⁻¹·{{< unit second >}}⁻¹. +``` +> The observed second-order rate constant was 2.1 × 10⁶ M⁻¹·s⁻¹. + +### Mass-to-Molar Concentration Conversion + +```markdown +A stock solution prepared at {{< massconc 0.5 mg/mL 350.4 micromolar >}} +was used for the binding assay. +``` +> A stock solution prepared at 0.5 mg·mL⁻¹ (1.43 μM, MW 350.4 g/mol) was +> used for the binding assay. + diff --git a/_extensions/units/_extension.yml b/_extensions/units/_extension.yml new file mode 100644 index 0000000..2e0a68e --- /dev/null +++ b/_extensions/units/_extension.yml @@ -0,0 +1,8 @@ + +title: Units +author: Alex Maldonado +version: 0.1.0 +quarto-required: ">=1.2.0" +contributes: + shortcodes: + - units.lua diff --git a/_extensions/units/units.lua b/_extensions/units/units.lua new file mode 100644 index 0000000..0e4ccf7 --- /dev/null +++ b/_extensions/units/units.lua @@ -0,0 +1,293 @@ +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 × 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, +}