feat: initial (slop) commit

This commit is contained in:
2026-07-07 00:18:15 -04:00
commit 221e5c31cb
3 changed files with 536 additions and 0 deletions
+235
View File
@@ -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 <unit-key> >}}` | Renders the typeset symbol for a single unit, with no numerical value or conversion. |
| `sci` | `{{< sci <value> >}}` | Renders a bare numerical value in scientific notation, independent of any unit. |
| `energy` | `{{< energy <value> <unit> [<target-unit>] >}}` | Bulk (non-molar) energy: J, kJ, cal, kcal. |
| `molarenergy` | `{{< molarenergy <value> <unit> [<target-unit>] >}}` | Molar energy: J/mol, kJ/mol, cal/mol, kcal/mol, eV, hartree, cm⁻¹. |
| `distance` | `{{< distance <value> <unit> [<target-unit>] >}}` | Distance: angstrom, nanometer, picometer, bohr, meter. |
| `time` | `{{< time <value> <unit> [<target-unit>] >}}` | Time: second and its SI-prefixed subdivisions, plus minute, hour, day. |
| `concentration` | `{{< concentration <value> <unit> [<target-unit>] >}}` | Molar concentration: M, mM, μM, nM, pM, fM. |
| `voltage` | `{{< voltage <value> <unit> [<target-unit>] >}}` | Electrical potential: kV, V, mV, μV. |
| `temperature` | `{{< temperature <value> <unit> [<target-unit>] >}}` | Temperature: kelvin, celsius, fahrenheit (affine conversion). |
| `massconc` | `{{< massconc <value> <mass-unit> <molar-mass> <target-unit> >}}` | 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 OH bond length is {{< distance 0.96 angstrom >}}.
Converted to nanometers: {{< distance 1.54 angstrom nanometer >}}.
```
> The OH 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.
+8
View File
@@ -0,0 +1,8 @@
title: Units
author: Alex Maldonado
version: 0.1.0
quarto-required: ">=1.2.0"
contributes:
shortcodes:
- units.lua
+293
View File
@@ -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 <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,
}