diff --git a/packages/preview/typed-smiles/0.4.2/LICENSE b/packages/preview/typed-smiles/0.4.2/LICENSE new file mode 100644 index 0000000000..4371ae1cf5 --- /dev/null +++ b/packages/preview/typed-smiles/0.4.2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Geronimo Castano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/typed-smiles/0.4.2/README.md b/packages/preview/typed-smiles/0.4.2/README.md new file mode 100644 index 0000000000..a8c42df2fc --- /dev/null +++ b/packages/preview/typed-smiles/0.4.2/README.md @@ -0,0 +1,409 @@ +# typed-smiles + +`typed-smiles` renders SMILES strings as clean 2D molecular diagrams in Typst. +It uses a small Rust/WASM plugin for parsing and layout, then draws the result +with CeTZ. + +The package is meant for chemistry notes, reaction schemes, reports, and +teaching material where you want molecules to live directly in your Typst +source instead of copying diagrams from a separate editor. + +**Full documentation:** see `docs/documentation.pdf` in the typed-smiles repository for every argument, syntax extension, color option, and reaction-scheme feature with live examples. + +--- + +## Quick start + +```typst +#import "@preview/typed-smiles:0.4.2": * +``` + +A wildcard import gives you the molecule renderer, reaction helpers, and +mechanism helpers: `smiles`, `ce`, `mol`, `rxn-arrow`, `reaction`, `atom`, +`bond`, `lp`, `species`, `arrow`, `highlight`, and `brackets`. + +## Basic molecule drawing + +Pass a SMILES string to `#smiles()` and it draws the skeletal structure. + +```typst +#import "@preview/typed-smiles:0.4.2": smiles + +#table( + columns: (1fr, 1fr, 1fr, 1fr), + gutter: 0em, row-gutter: 0em, + align: center + horizon, + stroke: 0.4pt + rgb("#d8d8d8"), + + [*Ethanol*], [*Alanine*], [*Chlorobenzene*], [*Furan*], + + [#smiles("CCO")], + [#smiles("CC(N)C(=O)O")], + [#smiles("ClC1=CC=CC=C1")], + [#smiles("C1=CC=CO1")], +) +``` + +![Basic molecule examples](assets/readme/basics.png) + +## Scaling + +`scale` resizes bond length, atom label size, and stroke together. Individual +overrides (`bond-length`, `font-size`, `bond-stroke`) let you tune one dimension +on its own. + +```typst +#table( + columns: (1fr, 1fr, 1fr), + gutter: 0em, row-gutter: 0em, + align: center + horizon, + stroke: 0.4pt + rgb("#d8d8d8"), + + [*Small*], [*Default*], [*Large*], + + [#smiles("C1=CC=CC=C1", scale: 0.8)], + [#smiles("C1=CC=CC=C1")], + [#smiles("C1=CC=CC=C1", scale: 1.4)], +) +``` + +![Balanced scaling examples](assets/readme/scaling.png) + +## Hydrogens, labels, and fonts + +Heteroatom hydrogens are shown by default; carbon hydrogens stay implicit. +Use `show-all-h: true` for carbon hydrogens, `[NH3]` bracket syntax for +explicit hydrogens, and `{label}` / `{label|style}` for custom group labels. +`font` sets the atom-label typeface. + +```typst +#table( + columns: (1fr, 1fr, 1fr, 1fr, 1fr), + gutter: 0em, row-gutter: 0em, + align: center + horizon, + stroke: 0.4pt + rgb("#d8d8d8"), + + [*Default hetero H*], [*All H*], [*Explicit H*], [*Colored label*], [*Custom font*], + + [#smiles("CC(N)C(=O)O")], + [#smiles("CCO", show-all-h: true)], + [#smiles("[NH3]")], + [#smiles("{PPh3|P}C=O")], + [#smiles("CCN", font: "Libertinus Serif")], +) +``` + +![Hydrogen and custom label examples](assets/readme/hydrogens-labels.png) + +## Lone pairs + +Set `lone-pairs` to `"dots"` or `"lines"` to annotate skeletal structures with +non-bonding electron pairs on common organic heteroatoms and charged atoms. + +```typst +#smiles("CCO", lone-pairs: "dots") +#smiles("CCN", lone-pairs: "lines") +#smiles("CC(=O)N", lone-pairs: "dots") +``` + +![Lone pair examples](assets/readme/lone-pairs.png) + +## Colors + +Atoms are colored with the Jmol CPK palette. Use `atom-colors` to override +specific elements or labeled groups per call, or use `.with()` to set +project-wide defaults. Label colors in `{label|style}` accept 17 named colors +or any `#RRGGBB` hex code. See the documentation for the full color reference. + +```typst +// Override an element and a specific label group: +#smiles("{PPh3}C({OEt})=O", + atom-colors: (O: rgb("#8B4513"), "{PPh3}": rgb("#7B2D8B"))) + +// Set defaults for the whole document in the preamble: +#let smiles = smiles.with( + bond-length: 0.9, + atom-colors: (O: rgb("#8B4513"), N: rgb("#008080")), +) + +// Hex and extra named colors in labels: +#smiles("{Cat|teal}C(=O){Nuc|#E040FB}") +``` + +![Color override examples](assets/readme/colors.png) + +> **Note:** `color: false` is a hard override — it makes everything black +> regardless of any `atom-colors` entries or inline label styles. +> To selectively highlight a group in an otherwise black-and-white diagram, +> keep `color: true` and drive everything through `atom-colors`. + +## Chemical formulas and equations + +`ce` is re-exported from `chemformula`, so one import covers both structures +and formulas. + +```typst +#import "@preview/typed-smiles:0.4.2": ce + +#table( + columns: (1fr, 1fr), + gutter: 0em, row-gutter: 0em, + align: center + horizon, + stroke: 0.4pt + rgb("#d8d8d8"), + + [#stack(spacing: 0.35cm, strong[Formula], ce("H2SO4"))], + [#stack(spacing: 0.35cm, strong[Ions], ce("(NH4)2SO4"))], + [#stack(spacing: 0.35cm, strong[Combustion], ce("CH4 + 2O2 -> CO2 + 2H2O"))], + [#stack(spacing: 0.35cm, strong[Equilibrium], ce("N2 + 3H2 <=> 2NH3"))], +) +``` + +![Chemical formula and equation examples](assets/readme/formulas.png) + +## Reaction schemes + +`reaction`, `rxn-arrow`, and `mol` compose molecules, formulas, and arrows into +schemes. `reaction(scale: 0.8)` shrinks the whole scheme uniformly. By default, +`reaction` is non-breakable — the entire block moves to the next page as a unit +if it does not fit. + +```typst +#import "@preview/typed-smiles:0.4.2": smiles, ce, rxn-arrow, mol, reaction + +#stack( + spacing: 1cm, + stack( + spacing: 0.4cm, + align(center, strong[Fischer esterification]), + align(center, reaction( + mol(smiles("CC(=O)O"), label: text(size: 8pt)[acetic acid]), + [+], + mol(smiles("CCO"), label: text(size: 8pt)[ethanol]), + rxn-arrow(above: ce("H+"), below: [heat]), + mol(smiles("CCOC(=O)C"), label: text(size: 8pt)[ethyl acetate]), + [+], + ce("H2O"), + )), + ), + stack( + spacing: 0.4cm, + align(center, strong[Electrophilic aromatic bromination]), + align(center, reaction( + mol(smiles("C1=CC=CC=C1"), label: text(size: 8pt)[benzene]), + rxn-arrow(above: ce("Br2"), below: ce("FeBr3")), + mol(smiles("BrC1=CC=CC=C1"), label: text(size: 8pt)[bromobenzene]), + )), + ), +) +``` + +![Reaction scheme examples](assets/readme/reactions.png) + +`rxn-arrow(kind: "equilibrium")` draws an open equilibrium arrow. Use +`kind: "equilibrium-filled"` for filled half-heads. + +```typst +#reaction( + ce("A"), + rxn-arrow(kind: "equilibrium", above: ce("H+"), below: [heat]), + ce("B"), + rxn-arrow(kind: "equilibrium-filled", above: [cat.]), + ce("C"), +) +``` + +## Multi-step mechanisms + +Reaction arrows can point right, left, up, or down for compact wrap-around +schemes. + +```typst +#stack( + spacing: 1.2em, + align(center, strong[Bromination, nitration, and reduction sequence]), + align(center, reaction( + mol(smiles("C1=CC=CC=C1"), label: text(size: 8pt)[1]), + rxn-arrow(above: ce("Br2"), below: ce("FeBr3")), + mol(smiles("BrC1=CC=CC=C1"), label: text(size: 8pt)[A]), + rxn-arrow(dir: "down", above: ce("HNO3"), below: ce("H2SO4")), + mol(smiles("BrC1=CC(=CC=C1)[N+](=O)[O-]"), label: text(size: 8pt)[B]), + rxn-arrow(dir: "left", above: ce("Fe"), below: ce("HCl")), + mol(smiles("BrC1=CC(=CC=C1)N"), label: text(size: 8pt)[C]), + )), +) +``` + +![Wrap-around reaction scheme](assets/readme/schemes.png) + +## Electron-pushing mechanisms + +`reaction()` also draws curly-arrow mechanisms. Atoms are referenced by their +writing-order index (0-based), so the SMILES string is never modified — pass +`show-indices: true` to read the numbers off the diagram while you write arrows. +On large mechanisms, `reaction(show-indices: true)` applies that overlay to all +string `mol("...")` molecules in the reaction, with per-molecule opt-out via +`mol("...", show-indices: false)`. +Pass a SMILES *string* to `mol(...)` (not `smiles(...)`) so the reaction renders it +itself and its atoms become addressable; `offset:` nudges a species so arrows read +cleanly. A curly `arrow()` or `highlight()` (or any `offset:`) switches `reaction()` +from a grid into one shared canvas — plain schemes are unaffected. + +```typst +#smiles( + "CC(=O)C", + lone-pairs: "dots", + highlight(bond(1, 2), fill: rgb("#FFE45C"), include-atoms: true), +) + +#reaction( + mol("[OH-]", lone-pairs: "dots", offset: (1.5, 1)), + mol("C(I)(C)C"), + arrow(from: lp(0, 0), to: atom(1, 0), bend: "left"), +) + +#brackets( + [#reaction(smiles("CC(=O)C"), rxn-arrow(), smiles("O=C=O"), scale: 0.55)], + sup: [‡], +) +``` + +References: `atom(s, i)`, `bond(s, i, j)`, `lp(s, i)` (the species index `s` is +optional inside a single `smiles()`), and `species(k)` for a whole `ce()`/content +item. Every reference takes an optional `offset: (dx, dy)`. + +![Electron-pushing mechanism examples](assets/readme/mechanisms.png) + +## Stereochemistry and drawing extensions + +`[C@H]` / `[C@@H]` mark tetrahedral centers; `/` and `\` describe cis/trans +geometry. `!w` forces a solid wedge and `!h` a hashed wedge. + +```typst +#table( + columns: (1fr, 1fr, 1fr, 1fr), + gutter: 0em, row-gutter: 0em, + align: center + horizon, + stroke: 0.4pt + rgb("#d8d8d8"), + + [*Manual wedge*], [*Manual hash*], [*Tetrahedral @@*], [*trans alkene*], + + [#smiles("C!wN")], + [#smiles("C!hN")], + [#smiles("N[C@@H](C)C(=O)O")], + [#smiles("F/C=C/F")], +) +``` + +![Stereochemistry and drawing extension examples](assets/readme/stereo-h.png) + +## API summary + +### `#smiles(smiles-str, …)` + +| Parameter | Default | Description | +|---|---|---| +| `smiles-str` | required | OpenSMILES string | +| `scale` | `1.0` | Balanced scale for bond length, labels, and stroke | +| `bond-length` | `none` | Bond length only (`1.0` = 30 pt per bond) | +| `font-size` | `none` | Atom-label size only | +| `font` | `"New Computer Modern"` | Atom-label font | +| `bond-stroke` | `none` | Bond width only | +| `color` | `true` | Apply Jmol CPK atom colors | +| `rotation` | `0deg` | Rotate molecule; labels stay upright | +| `show-all-h` | `false` | Label carbon implicit hydrogens | +| `lone-pairs` | `none` | Draw lone pairs as `"dots"` or `"lines"` | +| `atom-colors` | `(:)` | Color overrides: element key `O: red` or label key `"{PPh3}": blue` | +| `show-indices` | `false` | Stamp atom indices for writing arrow references | +| `…annotations` | — | `arrow()` / `highlight()` items on this molecule | + +SMILES string extensions: + +| Syntax | Meaning | +|---|---| +| `{label}` | Literal upright label at an atom position | +| `{label\|N}` | Label and bonds colored like element N | +| `{label\|red}` | Label colored with a named color (17 names supported) | +| `{label\|#RRGGBB}` | Label colored with a hex code | +| `!w` | Force a solid wedge on the next single bond | +| `!h` | Force a hashed wedge on the next single bond | + +### `#reaction(gap-h, gap-v, scale, breakable, show-indices, …items)` + +Lays out a scheme (grid) or, when any curly `arrow()`/`highlight()` or `mol(offset:)` +is present, an electron-pushing mechanism (shared canvas). + +| Parameter | Default | Description | +|---|---|---| +| `gap-h` | `1.5em` | Horizontal gap between items | +| `gap-v` | `1.5em` | Vertical gap between rows | +| `scale` | `1.0` | Uniform scale applied to the entire scheme | +| `breakable` | `false` | Allow splitting across pages | +| `show-indices` | `false` | Default atom-index overlay for string SMILES molecules in this reaction | + +### `#rxn-arrow(above, below, dir, kind)` + +| Parameter | Default | Description | +|---|---|---| +| `above` | `none` | Label above a horizontal arrow (or right of vertical) | +| `below` | `none` | Label below a horizontal arrow (or left of vertical) | +| `dir` | `"right"` | `"right"`, `"left"`, `"down"`, or `"up"` | +| `kind` | `"single"` | `"single"`, `"equilibrium"`, or `"equilibrium-filled"` | + +### `#mol(spec, label: none, offset: (0,0), …opts)` + +A reaction item. `spec` is any content (`smiles(...)`, `ce(...)`, text) or a SMILES +*string* — a string lets `reaction()` render it with addressable atoms. `offset` +nudges it in bond-length units. String molecules accept common drawing options +such as `font-size`, `font`, `bond-stroke`, `color`, `rotation`, `show-all-h`, +`lone-pairs`, `atom-colors`, and `show-indices`; use `reaction(scale: ...)` to +resize a shared mechanism canvas. + +### Mechanism helpers + +| Helper | Purpose | +|---|---| +| `atom(i)` / `atom(s, i)` | Atom center reference | +| `bond(i, j)` / `bond(s, i, j)` | Bond-midpoint reference | +| `lp(i)` / `lp(s, i)` | Lone-pair reference (`pair: n` to select) | +| `species(k)` | Bounding-box edge of a whole item | +| `arrow(from:, to:, label:, color:, bend:, angle:, half:)` | Curly electron arrow | +| `highlight(ref, fill:, stroke:, radius:)` | Shade an atom (disk) or bond (capsule) | +| `brackets(body, sup:, sub:)` | Square brackets with optional corner marks | + +All references accept an `offset: (dx, dy)` nudge. + +### `#ce(chem, font: none, font-size: none, …)` + +Re-exports `chemformula`'s `ch`. Accepts `font` and `font-size` for local +styling; other arguments pass through to chemformula. + +## SMILES support + +The package uses the [`smiles-parser`](https://crates.io/crates/smiles-parser) +crate for parsing. + +Current limitations: + +- Aromatic lowercase atoms are not parsed (`c1ccccc1` → use `C1=CC=CC=C1`). +- `@`/`@@` and `/`/`\` stereochemistry is depicted but R/S and E/Z descriptors are not computed. +- Bridged bicyclics may overlap; template matching is not implemented. +- Allene, square-planar, and octahedral stereochemistry are not supported. + +## Building + +```sh +cargo test --manifest-path plugin/Cargo.toml # Rust tests +./build.sh # build WASM plugin +typst compile --root . tests/test.typ tests/test.pdf # visual test +typst compile --root . docs/documentation.typ docs/documentation.pdf # user guide +``` + +## Architecture + +```text +SMILES string → Rust WASM plugin → JSON layout → CeTZ drawing in Typst +``` + +## License + +MIT diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/basics.png b/packages/preview/typed-smiles/0.4.2/assets/readme/basics.png new file mode 100644 index 0000000000..bf33a993f8 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/basics.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/colors.png b/packages/preview/typed-smiles/0.4.2/assets/readme/colors.png new file mode 100644 index 0000000000..3d43751097 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/colors.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/formulas.png b/packages/preview/typed-smiles/0.4.2/assets/readme/formulas.png new file mode 100644 index 0000000000..2fbe49142f Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/formulas.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/hydrogens-labels.png b/packages/preview/typed-smiles/0.4.2/assets/readme/hydrogens-labels.png new file mode 100644 index 0000000000..01a00035b3 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/hydrogens-labels.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/lone-pairs.png b/packages/preview/typed-smiles/0.4.2/assets/readme/lone-pairs.png new file mode 100644 index 0000000000..ddebedfb3c Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/lone-pairs.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/mechanisms.png b/packages/preview/typed-smiles/0.4.2/assets/readme/mechanisms.png new file mode 100644 index 0000000000..b6c710444a Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/mechanisms.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/reactions.png b/packages/preview/typed-smiles/0.4.2/assets/readme/reactions.png new file mode 100644 index 0000000000..54777761c9 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/reactions.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/scaling.png b/packages/preview/typed-smiles/0.4.2/assets/readme/scaling.png new file mode 100644 index 0000000000..6c3778ec98 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/scaling.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/schemes.png b/packages/preview/typed-smiles/0.4.2/assets/readme/schemes.png new file mode 100644 index 0000000000..ae0c2db9ca Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/schemes.png differ diff --git a/packages/preview/typed-smiles/0.4.2/assets/readme/stereo-h.png b/packages/preview/typed-smiles/0.4.2/assets/readme/stereo-h.png new file mode 100644 index 0000000000..050ddb87ad Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/assets/readme/stereo-h.png differ diff --git a/packages/preview/typed-smiles/0.4.2/plugin/typst_smiles_plugin.wasm b/packages/preview/typed-smiles/0.4.2/plugin/typst_smiles_plugin.wasm new file mode 100755 index 0000000000..7a0104e7f8 Binary files /dev/null and b/packages/preview/typed-smiles/0.4.2/plugin/typst_smiles_plugin.wasm differ diff --git a/packages/preview/typed-smiles/0.4.2/src/lib.typ b/packages/preview/typed-smiles/0.4.2/src/lib.typ new file mode 100644 index 0000000000..427177779b --- /dev/null +++ b/packages/preview/typed-smiles/0.4.2/src/lib.typ @@ -0,0 +1,1904 @@ +// Typst SMILES Package +// Renders SMILES strings as 2D molecular structure diagrams via a WASM plugin. +// Also re-exports `ce` from chemformula for chemical formula notation. + +#import "@preview/cetz:0.5.2" +#import "@preview/chemformula:0.1.3": ch + +#let smiles-plugin = plugin("../plugin/typst_smiles_plugin.wasm") + +// Re-export as ce so users only need one import line. +// chemformula uses math mode internally, giving proper operator spacing. +#let ce(chem, font: none, font-size: none, ..args) = { + if font == none and font-size == none { + ch(chem, ..args) + } else if font == none { + [ + #show math.equation: set text(size: font-size) + #ch(chem, ..args) + ] + } else if font-size == none { + [ + #show math.equation: set text(font: font) + #ch(chem, ..args) + ] + } else { + [ + #show math.equation: set text(font: font, size: font-size) + #ch(chem, ..args) + ] + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +#let _is-carbon(atom) = atom.symbol == "C" or atom.symbol == "c" + +#let _visible-implicit-h(atom, show-all-h: false) = { + let count = atom.at("implicit_h", default: 0) + if count == 0 { + 0 + } else if show-all-h { + count + } else if not _is-carbon(atom) and atom.symbol != "*" { + count + } else { + 0 + } +} + +#let _has-label(atom, show-all-h: false) = { + let has-abbrev = atom.at("abbrev", default: "") != "" + let has-hetero = (not _is-carbon(atom) and atom.symbol != "*") or (atom.charge != 0) + let has-isotope = atom.at("isotope", default: 0) > 0 + let has-explicit-h = atom.hcount > 0 and (show-all-h or not _is-carbon(atom)) + let has-implicit-h = _visible-implicit-h( + atom, + show-all-h: show-all-h, + ) > 0 + has-abbrev or has-hetero or has-isotope or has-explicit-h or has-implicit-h +} + +#let _atom-color(sym) = { + if sym == "N" or sym == "n" { rgb("#3050F8") } + else if sym == "O" or sym == "o" { rgb("#FF0D0D") } + else if sym == "S" or sym == "s" { rgb("#E6C800") } + else if sym == "P" { rgb("#FF8000") } + else if sym == "F" { rgb("#90E050") } + else if sym == "Cl" { rgb("#1FF01F") } + else if sym == "Br" { rgb("#A62929") } + else if sym == "I" { rgb("#940094") } + else { black } +} + +#let _label-color(style) = { + if style == "" { black } + else if style.starts-with("#") { rgb(style) } + else if style == "red" { rgb("#FF0D0D") } + else if style == "blue" { rgb("#3050F8") } + else if style == "green" { rgb("#1FA51F") } + else if style == "black" { black } + else if style == "gray" or style == "grey"{ rgb("#777777") } + else if style == "silver" { rgb("#C0C0C0") } + else if style == "white" { white } + else if style == "orange" { rgb("#FF8000") } + else if style == "yellow" { rgb("#E6C800") } + else if style == "brown" { rgb("#8B4513") } + else if style == "pink" { rgb("#FF69B4") } + else if style == "purple" { rgb("#940094") } + else if style == "cyan" { rgb("#00B4D8") } + else if style == "lime" { rgb("#32CD32") } + else if style == "teal" { rgb("#008080") } + else if style == "maroon" { rgb("#800000") } + else if style == "navy" { rgb("#000080") } + else { _atom-color(style) } +} + +// ── SMILES renderer ─────────────────────────────────────────────────────────── + +// Parse a SMILES string into layout JSON via the WASM plugin. +#let _layout(smiles-str) = json(smiles-plugin.layout(bytes(smiles-str))) + +// CeTZ canvas unit: one bond length is 30 pt at scale 1. +#let _canvas-scale(scale, bond-length) = ( + if bond-length == none { scale } else { bond-length } +) * 30pt + +// Draws a molecule's bonds, atom labels, and lone pairs into the current CeTZ +// canvas, centered at the local origin. The caller sets the canvas unit to +// `_canvas-scale(scale, bond-length)` and may translate before calling. Atom and +// bond references are resolved separately (see `_atom-pos`) with the same +// rotation, so annotations line up exactly with what is drawn here. +#let _draw-molecule( + layout, + scale: 1.0, + bond-length: none, + font-size: none, + font: "New Computer Modern", + bond-stroke: none, + color: true, + rotation: 0deg, + show-all-h: false, + lone-pairs: none, + atom-colors: (:), + show-indices: false, + index-prefix: "", +) = { + if lone-pairs != none and lone-pairs != "dots" and lone-pairs != "lines" { + panic("lone-pairs must be none, \"dots\", or \"lines\"") + } + + let actual-bond-length = if bond-length == none { scale } else { bond-length } + let actual-font-size = if font-size == none { 11pt * scale } else { font-size } + let actual-bond-stroke = if bond-stroke == none { 0.9pt * scale } else { bond-stroke } + let canvas-scale = actual-bond-length * 30pt + let stroke-units = actual-bond-stroke / canvas-scale + + let label-margin = calc.max(0.27, actual-font-size / canvas-scale * 0.70) + let double-gap = calc.max(0.065, stroke-units * 2.30) + let ring-double-gap = calc.max(0.09, stroke-units * 3.00) + let junction-overlap = calc.min(0.006, calc.max(0.00875, stroke-units * 0.49)) + let inner-trim = 0.07 + let multiple-bond-trim = 0.10 + let stroke-w = actual-bond-stroke + let subscript-size = actual-font-size * 1.00 + let superscript-size = actual-font-size * 1.00 + let lone-pair-offset = calc.max(0.1, actual-font-size / canvas-scale * 0.6) + let lone-pair-terminal-offset = calc.max(0.1, actual-font-size / canvas-scale * 0.5) + let lone-pair-dot-r = calc.max(0.018, stroke-units * 0.75) + let lone-pair-dot-gap = calc.max(0.050, lone-pair-dot-r * 2) + let lone-pair-line-half = calc.max(0.055, stroke-units * 2.4) + + let atom-clr = if color { + (sym) => { if sym in atom-colors { atom-colors.at(sym) } else { _atom-color(sym) } } + } else { (sym) => black } + let label-clr = if color { + (style) => { if style in atom-colors { atom-colors.at(style) } else { _label-color(style) } } + } else { (style) => black } + let display-clr(atom) = { + let abbrev = atom.at("abbrev", default: "") + let abbrev-style = atom.at("abbrev_style", default: "") + if abbrev != "" { + let label-key = "{" + abbrev + "}" + if color and label-key in atom-colors { atom-colors.at(label-key) } + else { label-clr(abbrev-style) } + } else { + atom-clr(atom.symbol) + } + } + let atom-label(body, fill: black, size: actual-font-size) = text( + size: size, + font: font, + style: "normal", + weight: "regular", + fill: fill, + body, + ) + let transparent = rgb(0, 0, 0, 0) + let content-width(body) = measure(body).width / canvas-scale + let index-marker-name(i, suffix) = index-prefix + "atom-index-marker-" + str(i) + suffix + + let cos-a = calc.cos(rotation) + let sin-a = calc.sin(rotation) + let rx(x, y) = x * cos-a - y * sin-a + let ry(x, y) = x * sin-a + y * cos-a + + // cetz.draw exports a `scale` transform that would shadow the `scale` argument, + // so import only after all scalar/style values above are computed. + import cetz.draw: * + + // Virtual bonds connect a heavy atom to its bracket-H atoms. They carry positions + // for atom() references but must not affect structural computations or rendering. + let real-bonds = layout.bonds.filter(b => not b.at("virtual_bond", default: false)) + let vh-parent = (:) + let vh-child = (:) + let vh-dir = (:) + for b in layout.bonds { + if b.at("virtual_bond", default: false) { + let pi = b.from + let hi = b.to + vh-parent.insert(str(hi), pi) + vh-child.insert(str(pi), hi) + let p = layout.atoms.at(pi) + let h = layout.atoms.at(hi) + let dx = rx(h.pos.x, h.pos.y) - rx(p.pos.x, p.pos.y) + let dy = ry(h.pos.x, h.pos.y) - ry(p.pos.x, p.pos.y) + let d = calc.sqrt(dx * dx + dy * dy) + let ux = if d > 0.001 { dx / d } else { 1.0 } + let uy = if d > 0.001 { dy / d } else { 0.0 } + vh-dir.insert(str(pi), (ux, uy)) + } + } + + let atom-degree(i) = real-bonds.filter(b => b.from == i or b.to == i).len() + let atom-neighbor-indices(i) = { + let indices = () + for b in real-bonds { + if b.from == i { + indices.push(b.to) + } else if b.to == i { + indices.push(b.from) + } + } + indices + } + let force-linear-carbon-label(i) = { + let atom = layout.atoms.at(i) + if not _is-carbon(atom) or atom.at("abbrev", default: "") != "" or atom-degree(i) != 2 { + false + } else { + let bonds = real-bonds.filter(b => b.from == i or b.to == i) + let double-count = bonds.filter(b => b.order == 2).len() + let has-carbon-neighbor = atom-neighbor-indices(i).any(ni => _is-carbon(layout.atoms.at(ni))) + double-count == 2 and not has-carbon-neighbor + } + } + let has-label(i) = { + let atom = layout.atoms.at(i) + _has-label(atom, show-all-h: show-all-h) or force-linear-carbon-label(i) + } + let first-neighbor(i) = { + let result = none + for b in real-bonds { + if result == none and (b.from == i or b.to == i) { + result = if b.from == i { b.to } else { b.from } + } + } + result + } + let visible-h-count(atom) = { + let count = atom.hcount + _visible-implicit-h(atom, show-all-h: show-all-h) + if atom.at("stereo_h", default: "none") != "none" { + calc.max(0, count - 1) + } else { + count + } + } + let label-trim(atom, i) = { + if not has-label(i) { + 0.0 + } else if visible-h-count(atom) > 0 and atom-degree(i) == 1 and not _is-carbon(atom) { + 0.06 + } else { + label-margin + } + } + + for bond in real-bonds { + let af = layout.atoms.at(bond.from) + let at = layout.atoms.at(bond.to) + let p1x = rx(af.pos.x, af.pos.y) + let p1y = ry(af.pos.x, af.pos.y) + let p2x = rx(at.pos.x, at.pos.y) + let p2y = ry(at.pos.x, at.pos.y) + let c1 = display-clr(af) + let c2 = display-clr(at) + + let dx = p2x - p1x + let dy = p2y - p1y + let len = calc.sqrt(dx * dx + dy * dy) + let ux = if len > 0.001 { dx / len } else { 1.0 } + let uy = if len > 0.001 { dy / len } else { 0.0 } + + let af-has-label = has-label(bond.from) + let at-has-label = has-label(bond.to) + let s1 = label-trim(af, bond.from) + let s2 = label-trim(at, bond.to) + let e1 = if not af-has-label and atom-degree(bond.from) > 1 { junction-overlap } else { 0.0 } + let e2 = if not at-has-label and atom-degree(bond.to) > 1 { junction-overlap } else { 0.0 } + let q1x = p1x + ux * s1 - ux * e1 + let q1y = p1y + uy * s1 - uy * e1 + let q2x = p2x - ux * s2 + ux * e2 + let q2y = p2y - uy * s2 + uy * e2 + let mx = (q1x + q2x) / 2 + let my = (q1y + q2y) / 2 + + let stereo = bond.at("stereo", default: "none") + let split-line(x1, y1, x2, y2, from-color, to-color) = { + let xmid = (x1 + x2) / 2 + let ymid = (y1 + y2) / 2 + line((x1, y1), (xmid, ymid), stroke: stroke-w + from-color) + line((xmid, ymid), (x2, y2), stroke: stroke-w + to-color) + } + let offset-side(atom-idx, other-idx, px, py) = { + let side = 1.0 + for b in real-bonds { + if (b.from == atom-idx or b.to == atom-idx) and not (b.from == other-idx or b.to == other-idx) { + let ni = if b.from == atom-idx { b.to } else { b.from } + let na = layout.atoms.at(ni) + let vx = rx(na.pos.x, na.pos.y) - px + let vy = ry(na.pos.x, na.pos.y) - py + side = if vx * (-uy) + vy * ux >= 0.0 { 1.0 } else { -1.0 } + } + } + side + } + + // Per IUPAC: narrow tip at stereocenter, wide base at substituent. + // Heuristic: if exactly one atom is a plain C, the tip goes there; + // otherwise fall back to the higher-degree atom. + let af-abbr = af.at("abbrev", default: "") != "" + let at-abbr = at.at("abbrev", default: "") != "" + let af-is-c = (af.symbol == "C" or af.symbol == "c") and not af-abbr + let at-is-c = (at.symbol == "C" or at.symbol == "c") and not at-abbr + let tip-at-from = if af-is-c and not at-is-c { true } + else if at-is-c and not af-is-c { false } + else { + let df = atom-degree(bond.from) + let dt = atom-degree(bond.to) + df >= dt + } + + if stereo == "wedge_up" { + let half-w = 0.10 + let ox = -uy * half-w + let oy = ux * half-w + // Bicolor the wedge at its midpoint, like a plain bond. + let (tx, ty, bx, by, tip-c, base-c) = if tip-at-from { + (q1x, q1y, q2x, q2y, c1, c2) + } else { + (q2x, q2y, q1x, q1y, c2, c1) + } + let mx2 = (tx + bx) / 2 + let my2 = (ty + by) / 2 + line( + (tx, ty), (mx2 + ox / 2, my2 + oy / 2), (mx2 - ox / 2, my2 - oy / 2), + close: true, fill: tip-c, stroke: none, + ) + line( + (mx2 + ox / 2, my2 + oy / 2), (bx + ox, by + oy), + (bx - ox, by - oy), (mx2 - ox / 2, my2 - oy / 2), + close: true, fill: base-c, stroke: none, + ) + + } else if stereo == "wedge_down" { + // Hashed wedge: perpendicular lines widening from tip to base. + let n-lines = 7 + let half-w = 0.10 + let (sx, sy, ex, ey, near-c, far-c) = if tip-at-from { + (q1x, q1y, q2x, q2y, c1, c2) + } else { + (q2x, q2y, q1x, q1y, c2, c1) + } + for i in range(n-lines) { + let t = (i + 1) / (n-lines + 1) + let cx = sx + (ex - sx) * t + let cy = sy + (ey - sy) * t + let w = half-w * t + let ox = -uy * w + let oy = ux * w + let c = if t < 0.5 { near-c } else { far-c } + line((cx - ox, cy - oy), (cx + ox, cy + oy), stroke: stroke-w + c) + } + + } else if bond.order == 1 { + line((q1x, q1y), (mx, my), stroke: stroke-w + c1) + line((mx, my), (q2x, q2y), stroke: stroke-w + c2) + + } else if bond.order == 2 { + let is-ring-bond = bond.inner_x != 0.0 or bond.inner_y != 0.0 + + if is-ring-bond { + split-line(q1x, q1y, q2x, q2y, c1, c2) + let ix = rx(bond.inner_x, bond.inner_y) * ring-double-gap + let iy = ry(bond.inner_x, bond.inner_y) * ring-double-gap + let lx1 = q1x + ux * inner-trim + ix + let ly1 = q1y + uy * inner-trim + iy + let lx2 = q2x - ux * inner-trim + ix + let ly2 = q2y - uy * inner-trim + iy + split-line(lx1, ly1, lx2, ly2, c1, c2) + } else { + let deg-f = atom-degree(bond.from) + let deg-t = atom-degree(bond.to) + let has-hidden-simple-continuation = (deg-f == 2 and not af-has-label) or (deg-t == 2 and not at-has-label) + let has-real-junction = deg-f > 2 or deg-t > 2 + + if has-hidden-simple-continuation and not has-real-junction { + split-line(q1x, q1y, q2x, q2y, c1, c2) + + let side = if deg-f == 2 and not af-has-label { + offset-side(bond.from, bond.to, q1x, q1y) + } else { + offset-side(bond.to, bond.from, q2x, q2y) + } + let trim = calc.min(multiple-bond-trim, len * 0.25) + let simple-double-gap = double-gap * 1.12 + let ox = -uy * simple-double-gap * side + let oy = ux * simple-double-gap * side + let lx1 = q1x + ux * (if deg-f == 2 and not af-has-label { trim } else { 0.0 }) + ox + let ly1 = q1y + uy * (if deg-f == 2 and not af-has-label { trim } else { 0.0 }) + oy + let lx2 = q2x - ux * (if deg-t == 2 and not at-has-label { trim } else { 0.0 }) + ox + let ly2 = q2y - uy * (if deg-t == 2 and not at-has-label { trim } else { 0.0 }) + oy + split-line(lx1, ly1, lx2, ly2, c1, c2) + } else { + // At real junctions (3+ bonds) extend slightly to close corner gap. + let ext = 0.04 + let ox = -uy * double-gap + let oy = ux * double-gap + let e1x = q1x - ux * (if deg-f > 2 { ext } else { 0.0 }) + let e1y = q1y - uy * (if deg-f > 2 { ext } else { 0.0 }) + let e2x = q2x + ux * (if deg-t > 2 { ext } else { 0.0 }) + let e2y = q2y + uy * (if deg-t > 2 { ext } else { 0.0 }) + split-line(e1x + ox, e1y + oy, e2x + ox, e2y + oy, c1, c2) + split-line(e1x - ox, e1y - oy, e2x - ox, e2y - oy, c1, c2) + } + } + + } else if bond.order == 3 { + let ox = -uy * double-gap * 1.3 + let oy = ux * double-gap * 1.3 + let trim = calc.min(multiple-bond-trim, len * 0.25) + split-line(q1x, q1y, q2x, q2y, c1, c2) + split-line(q1x + ux * trim + ox, q1y + uy * trim + oy, q2x - ux * trim + ox, q2y - uy * trim + oy, c1, c2) + split-line(q1x + ux * trim - ox, q1y + uy * trim - oy, q2x - ux * trim - ox, q2y - uy * trim - oy, c1, c2) + + } else if bond.order == 4 { + split-line(q1x, q1y, q2x, q2y, c1, c2) + let ox = -uy * double-gap + let oy = ux * double-gap + line( + (q1x + ox, q1y + oy), (mx + ox, my + oy), + stroke: (paint: c1, thickness: stroke-w, dash: "dashed"), + ) + line( + (mx + ox, my + oy), (q2x + ox, q2y + oy), + stroke: (paint: c2, thickness: stroke-w, dash: "dashed"), + ) + } + } + + // ── Lone-pair annotation ────────────────────────────────────────────── + // Non-bonding electron pairs render as either two dots (the two electrons) + // or a single short line. Hydrogen-bearing heteroatom labels are laid out + // in screen space so the pairs avoid the bond and the inline hydrogen; all + // other labeled atoms use the layout directions supplied by the plugin. + + // Unit vector in screen space pointing from atom `i` toward atom `j`, + // or none when the two atoms coincide. + let neighbor-screen-dir(i, j) = { + let a = layout.atoms.at(i).pos + let b = layout.atoms.at(j).pos + let vx = rx(b.x, b.y) - rx(a.x, a.y) + let vy = ry(b.x, b.y) - ry(a.x, a.y) + let len = calc.sqrt(vx * vx + vy * vy) + if len > 0.001 { (x: vx / len, y: vy / len) } else { none } + } + + // Cardinal screen directions for a hydrogen-bearing heteroatom, chosen + // greedily to stay clear of the bonds, the inline hydrogen, and one another. + let inline-h-pair-dirs(i, count) = { + let occupied = () + for ni in atom-neighbor-indices(i) { + let d = neighbor-screen-dir(i, ni) + if d != none { occupied.push(d) } + } + if atom-degree(i) >= 2 { + // A stacked hydrogen is drawn directly above the symbol. + occupied.push((x: 0.0, y: 1.0)) + } else { + // An inline "XH" runs horizontally: the hydrogen sits opposite a + // horizontal bond, or to the right of a vertical one. + let ni = first-neighbor(i) + let d = if ni != none { neighbor-screen-dir(i, ni) } else { none } + let h-dir = if d == none { + (x: 1.0, y: 0.0) + } else if calc.abs(d.x) >= calc.abs(d.y) { + (x: -d.x, y: 0.0) + } else { + (x: 1.0, y: 0.0) + } + occupied.push(h-dir) + } + + let candidates = if count == 1 { + ((x: 0.0, y: -1.0), (x: 0.0, y: 1.0), (x: -1.0, y: 0.0), (x: 1.0, y: 0.0)) + } else { + ((x: 0.0, y: 1.0), (x: 0.0, y: -1.0), (x: -1.0, y: 0.0), (x: 1.0, y: 0.0)) + } + + let chosen = () + for _ in range(count) { + let best = none + let best-score = none + for cand in candidates { + let score = 0.0 + for occ in occupied { + let dot = cand.x * occ.x + cand.y * occ.y + if dot > 0.85 { + score += 100.0 + } else if dot > 0.45 { + score += 8.0 + } else if dot > 0.10 { + score += 1.0 + } + } + for sel in chosen { + if cand.x * sel.x + cand.y * sel.y > 0.85 { score += 100.0 } + } + if best-score == none or score < best-score { + best = cand + best-score = score + } + } + if best != none { + chosen.push(best) + occupied.push(best) + } + } + chosen + } + + // Draw each direction in `dirs` (screen-space unit vectors) as one electron + // pair around `origin`: two dots in "dots" mode, a short line in "lines". + // `origin` can be a numeric point or a named glyph marker. + let render-pairs(origin, dirs, fill, offset) = { + let point-at(dx, dy) = if type(origin) == str or type(origin) == dictionary { + (to: origin, rel: (dx, dy)) + } else { + (origin.x + dx, origin.y + dy) + } + for dir in dirs { + let ox = -dir.y + let oy = dir.x + if lone-pairs == "dots" { + circle( + point-at( + dir.x * offset + ox * lone-pair-dot-gap / 2, + dir.y * offset + oy * lone-pair-dot-gap / 2, + ), + radius: lone-pair-dot-r, + fill: fill, + stroke: none, + ) + circle( + point-at( + dir.x * offset - ox * lone-pair-dot-gap / 2, + dir.y * offset - oy * lone-pair-dot-gap / 2, + ), + radius: lone-pair-dot-r, + fill: fill, + stroke: none, + ) + } else { + line( + point-at( + dir.x * offset - ox * lone-pair-line-half, + dir.y * offset - oy * lone-pair-line-half, + ), + point-at( + dir.x * offset + ox * lone-pair-line-half, + dir.y * offset + oy * lone-pair-line-half, + ), + stroke: stroke-w + fill, + ) + } + } + } + + let draw-lone-pairs() = { + if lone-pairs == none { return } + for i in range(layout.atoms.len()) { + let atom = layout.atoms.at(i) + if atom.at("virtual_h", default: false) { continue } + let count = atom.at("lone_pairs", default: 0) + if count <= 0 { continue } + + let px = rx(atom.pos.x, atom.pos.y) + let py = ry(atom.pos.x, atom.pos.y) + let fill = display-clr(atom) + let has-inline-h = ( + atom.at("abbrev", default: "") == "" and + not _is-carbon(atom) and + visible-h-count(atom) > 0 + ) + + if not has-inline-h { + // Use the plugin's layout-space directions, rotated into screen space. + let dirs = atom + .at("lone_pair_dirs", default: ()) + .map(d => (x: rx(d.x, d.y), y: ry(d.x, d.y))) + render-pairs((x: px, y: py), dirs, fill, lone-pair-offset) + } else { + let origin = if str(i) in vh-child { + index-marker-name(i, "-sym") + ".center" + } else { + (x: px, y: py) + } + let offset = if atom-degree(i) <= 1 { lone-pair-terminal-offset } else { lone-pair-offset } + render-pairs(origin, inline-h-pair-dirs(i, count), fill, offset) + } + } + } + + // Stereochemical hydrogens are drawn as explicit H labels only when they + // carry wedge/hash information from a bracket stereocenter. + for i in range(layout.atoms.len()) { + let atom = layout.atoms.at(i) + if atom.at("virtual_h", default: false) { continue } + let stereo = atom.at("stereo_h", default: "none") + if stereo != "none" { + let px = rx(atom.pos.x, atom.pos.y) + let py = ry(atom.pos.x, atom.pos.y) + let dir = atom.at("stereo_h_dir", default: (x: 0.0, y: -1.0)) + let ux = rx(dir.x, dir.y) + let uy = ry(dir.x, dir.y) + let bond-end-x = px + ux * 0.62 + let bond-end-y = py + uy * 0.62 + let label-x = px + ux * 0.82 + let label-y = py + uy * 0.82 + let h-fill = atom-clr("H") + + if stereo == "wedge_up" { + let half-w = 0.085 + let ox = -uy * half-w + let oy = ux * half-w + line( + (px, py), (bond-end-x + ox, bond-end-y + oy), (bond-end-x - ox, bond-end-y - oy), + close: true, fill: h-fill, stroke: none, + ) + } else if stereo == "wedge_down" { + let n-lines = 7 + let half-w = 0.085 + for j in range(n-lines) { + let t = (j + 1) / (n-lines + 1) + let cx = px + (bond-end-x - px) * t + let cy = py + (bond-end-y - py) * t + let w = half-w * t + let ox = -uy * w + let oy = ux * w + line((cx - ox, cy - oy), (cx + ox, cy + oy), stroke: stroke-w + h-fill) + } + } + + content( + (label-x, label-y), + atom-label("H", fill: h-fill), + anchor: "center", + padding: 1pt, + ) + } + } + + // Atom labels — heteroatoms, charged atoms, and literal groups. + // Positions are rotated; text content stays upright. + for i in range(layout.atoms.len()) { + let atom = layout.atoms.at(i) + if atom.at("virtual_h", default: false) { continue } + if has-label(i) { + let abbrev = atom.at("abbrev", default: "") + let fill = display-clr(atom) + let px = rx(atom.pos.x, atom.pos.y) + let py = ry(atom.pos.x, atom.pos.y) + let deg = atom-degree(i) + let h-count = visible-h-count(atom) + + let charge-str = if atom.charge == 1 { "+" } + else if atom.charge == -1 { "\u{2212}" } + else if atom.charge > 1 { str(atom.charge) + "+" } + else if atom.charge < -1 { str(-atom.charge) + "\u{2212}" } + else { "" } + // A small gap before the superscript so the sign sits to the right of a + // preceding subscript (e.g. the "3" in NH3+) instead of reading as that + // digit's exponent. + let charge-content = if charge-str == "" { + [] + } else { + h(0.12em) + super(atom-label(charge-str, fill: fill, size: superscript-size)) + } + + let isotope = atom.at("isotope", default: 0) + let isotope-content = if isotope > 0 { + super(atom-label(str(isotope), fill: fill, size: superscript-size)) + } else { + [] + } + let sym-text = isotope-content + atom-label(atom.symbol, fill: fill) + let h-text = if abbrev != "" or h-count == 0 or (_is-carbon(atom) and not show-all-h) { + [] + } else if h-count == 1 { + atom-label("H", fill: fill) + } else { + atom-label("H", fill: fill) + sub(atom-label( + str(h-count), + fill: fill, + size: subscript-size, + )) + } + + // Terminal heteroatom with an inline H (e.g. -OH, -NH₂): center the heavy + // symbol on the bond terminus and hang the H off to one side, so the bond + // always meets the heteroatom and never the trailing H at any angle. + let hetero-inline = abbrev == "" and h-text != [] and deg == 1 and not _is-carbon(atom) + + if hetero-inline { + let ni = first-neighbor(i) + let nb = layout.atoms.at(ni) + let dx = rx(nb.pos.x, nb.pos.y) - px + let dy = ry(nb.pos.x, nb.pos.y) - py + // Anchor the symbol's bond-facing edge at the atom; the H hangs off the + // opposite side. Pad only the bond-facing edge so the pair stays tight. + let pad-bond = 1pt + let (sym-anchor, sym-pad, h-at, h-self) = if calc.abs(dx) >= calc.abs(dy) { + if dx > 0 { + ("east", (right: pad-bond), "west", "east") + } else { + ("west", (left: pad-bond), "east", "west") + } + } else if dy > 0 { + // No vertical padding: it would skew the top anchor and lift the H + // out of line with the symbol. The bond trim supplies the gap. + ("north", 0pt, "east", "west") + } else { + ("south", 0pt, "east", "west") + } + // Keep the charge on the rightmost element so it reads as the group + // charge: with the symbol when the H sits to its left, else with the H. + let sym-content = if h-at == "west" { sym-text + charge-content } else { sym-text } + let h-content = if h-at == "west" { h-text } else { h-text + charge-content } + let sname = "atom-" + str(i) + content((px, py), sym-content, anchor: sym-anchor, padding: sym-pad, name: sname) + if (show-indices or lone-pairs != none) and str(i) in vh-child { + let h-child = vh-child.at(str(i)) + let pad-u = pad-bond / canvas-scale + let sym-marker-x = if sym-anchor == "east" { + px - pad-u - content-width(if h-at == "west" { charge-content } else { [] }) + } else if sym-anchor == "west" { + px + pad-u + } else { + px + } + content( + (sym-marker-x, py), + atom-label(atom.symbol, fill: transparent), + anchor: sym-anchor, + padding: 0pt, + name: index-marker-name(i, "-sym"), + ) + content( + if h-self == "east" { + ( + to: sname + ".north-" + h-at, + rel: (content-width(atom-label("H", fill: transparent)) - content-width(h-text), 0), + ) + } else { + sname + ".north-" + h-at + }, + atom-label("H", fill: transparent), + anchor: "north-" + h-self, + padding: 0pt, + name: index-marker-name(h-child, "-h"), + ) + } + // Attach the H at the symbol's top corner so their baselines align. + content( + sname + ".north-" + h-at, + h-content, + anchor: "north-" + h-self, + padding: 0pt, + ) + } else { + let draw-h-above = false + let h-above-content = [] + let h-above-marker = none + let label-content = if abbrev != "" { + atom-label(abbrev, fill: fill) + } else { + let reverse-inline = if h-text == [] or deg != 1 { + false + } else { + let ni = first-neighbor(i) + if ni == none { + false + } else { + let neighbor = layout.atoms.at(ni) + let vx = rx(neighbor.pos.x, neighbor.pos.y) - px + vx > 0.05 + } + } + let stacked-h = h-text != [] and deg >= 2 and not _is-carbon(atom) + let base-text = if stacked-h { + draw-h-above = true + h-above-content = h-text + if (show-indices or lone-pairs != none) and str(i) in vh-child { + h-above-marker = ( + child: vh-child.at(str(i)), + group: h-text, + prefix: [], + fragment: atom-label("H", fill: transparent), + ) + } + sym-text + } else if reverse-inline { + h-text + sym-text + } else { + sym-text + h-text + } + base-text + charge-content + } + + let symbol-centered-charge = abbrev == "" and h-text == [] and charge-content != [] + + if symbol-centered-charge { + content( + (px + (content-width(label-content) - content-width(sym-text)) / 2, py), + label-content, + anchor: "center", + padding: 1pt, + ) + } else if draw-h-above { + let h-center = (x: px, y: py + label-margin * 0.95) + content( + (h-center.x, h-center.y), + h-above-content, + anchor: "center", + padding: 1pt, + ) + if h-above-marker != none { + let sym-marker = atom-label(atom.symbol, fill: transparent) + let sym-x = ( + px + - content-width(label-content) / 2 + + content-width(sym-marker) / 2 + ) + content( + (sym-x, py), + sym-marker, + anchor: "center", + padding: 0pt, + name: index-marker-name(i, "-sym"), + ) + } + if h-above-marker != none { + let marker-x = ( + h-center.x + - content-width(h-above-marker.group) / 2 + + content-width(h-above-marker.prefix) + + content-width(h-above-marker.fragment) / 2 + ) + content( + (marker-x, h-center.y), + h-above-marker.fragment, + anchor: "center", + padding: 0pt, + name: index-marker-name(h-above-marker.child, "-h"), + ) + } + } + + if (show-indices or lone-pairs != none) and str(i) in vh-child and not draw-h-above { + let h-child = vh-child.at(str(i)) + let sym-marker = atom-label(atom.symbol, fill: transparent) + let h-marker = atom-label("H", fill: transparent) + let h-prefix = if label-content == h-text + sym-text + charge-content { + [] + } else { + sym-text + } + let sym-prefix = if h-prefix == [] { h-text } else { [] } + let sym-x = ( + px + - content-width(label-content) / 2 + + content-width(sym-prefix) + + content-width(sym-marker) / 2 + ) + let h-x = ( + px + - content-width(label-content) / 2 + + content-width(h-prefix) + + content-width(h-marker) / 2 + ) + content( + (sym-x, py), + sym-marker, + anchor: "center", + padding: 0pt, + name: index-marker-name(i, "-sym"), + ) + content( + (h-x, py), + h-marker, + anchor: "center", + padding: 0pt, + name: index-marker-name(h-child, "-h"), + ) + } + + if not symbol-centered-charge { + content((px, py), label-content, anchor: "center", padding: 1pt) + } + } + } + } + + draw-lone-pairs() + + // Development overlay: stamp each atom's writing-order index so users can + // read off the numbers used by atom()/bond()/lp() references. + // + // Bracket-H labels (e.g. "H₄" in NH₄⁺) are drawn as combined text, while + // the heavy atom and H remain separate addressable indices. Hidden same-font + // fragment markers expose the measured glyph centers for the overlay. + if show-indices { + let badge-size = actual-font-size * 0.52 + + for i in range(layout.atoms.len()) { + let a = layout.atoms.at(i) + let ax = rx(a.pos.x, a.pos.y) + let ay = ry(a.pos.x, a.pos.y) + + let (bx, by, btarget) = if ( + not a.at("virtual_h", default: false) and + str(i) in vh-child and + _has-label(a, show-all-h: show-all-h) + ) { + (0.0, 0.0, index-marker-name(i, "-sym") + ".center") + } else if ( + a.at("virtual_h", default: false) and + str(i) in vh-parent and + _has-label(layout.atoms.at(vh-parent.at(str(i))), show-all-h: show-all-h) + ) { + (0.0, 0.0, index-marker-name(i, "-h") + ".center") + } else { + (ax, ay, none) + } + + content( + if btarget == none { (bx, by) } else { btarget }, + box( + fill: rgb(255, 255, 255, 220), + inset: 0.4pt, + text(size: badge-size, fill: rgb("#C81E6E"), weight: "bold", str(i)), + ), + anchor: "center", + padding: 0pt, + ) + } + } +} + +// ── Reference resolution and annotation drawing ───────────────────────────────── + +// Screen-space position of layout coordinate (x, y) under `rotation`. +#let _rot(x, y, rotation) = ( + x * calc.cos(rotation) - y * calc.sin(rotation), + x * calc.sin(rotation) + y * calc.cos(rotation), +) + +// Absolute canvas position of atom `i` in a placed species. Label references use +// the rendered atom glyph center rather than the full label box. +#let _atom-pos(sp, i) = { + let a = sp.layout.atoms.at(i) + let (rxv, ryv) = _rot(a.pos.x, a.pos.y, sp.rotation) + let base = (sp.origin.at(0) + rxv, sp.origin.at(1) + ryv) + + let cs = sp.at("canvas-scale", default: 30pt) + let fs = sp.at("actual-font-size", default: 11pt) + let font = sp.at("font", default: "New Computer Modern") + let show-all-h = sp.at("show-all-h", default: false) + let label-margin = calc.max(0.27, fs / cs * 0.70) + let subscript-size = fs * 1.00 + let superscript-size = fs * 1.00 + let atom-label(body, size: fs) = text( + size: size, + font: font, + style: "normal", + weight: "regular", + body, + ) + let content-width(body) = measure(body).width / cs + let content-height(body) = measure(body).height / cs + let real-bonds = sp.layout.bonds.filter(b => not b.at("virtual_bond", default: false)) + let atom-degree(j) = real-bonds.filter(b => b.from == j or b.to == j).len() + let first-neighbor(j) = { + let result = none + for b in real-bonds { + if result == none and (b.from == j or b.to == j) { + result = if b.from == j { b.to } else { b.from } + } + } + result + } + let charge-content(atom) = { + let charge-str = if atom.charge == 1 { "+" } + else if atom.charge == -1 { "\u{2212}" } + else if atom.charge > 1 { str(atom.charge) + "+" } + else if atom.charge < -1 { str(-atom.charge) + "\u{2212}" } + else { "" } + if charge-str == "" { + [] + } else { + h(0.12em) + super(atom-label(charge-str, size: superscript-size)) + } + } + let h-text(atom) = { + let count = atom.hcount + _visible-implicit-h(atom, show-all-h: show-all-h) + if atom.at("abbrev", default: "") != "" or count == 0 or (_is-carbon(atom) and not show-all-h) { + [] + } else if count == 1 { + atom-label("H") + } else { + atom-label("H") + sub(atom-label(str(count), size: subscript-size)) + } + } + let virtual-child(parent) = { + let child = none + for b in sp.layout.bonds { + if child == none and b.at("virtual_bond", default: false) and b.from == parent { + child = b.to + } + } + child + } + let virtual-parent(child) = { + let parent = none + for b in sp.layout.bonds { + if parent == none and b.at("virtual_bond", default: false) and b.to == child { + parent = b.from + } + } + parent + } + + let label-fragment-pos(parent, fragment) = { + let atom = sp.layout.atoms.at(parent) + let (pxr, pyr) = _rot(atom.pos.x, atom.pos.y, sp.rotation) + let px = sp.origin.at(0) + pxr + let py = sp.origin.at(1) + pyr + let deg = atom-degree(parent) + let sym-text = atom-label(atom.symbol) + let ht = h-text(atom) + let charge = charge-content(atom) + + if atom.at("abbrev", default: "") != "" or ht == [] { + return (px, py) + } + + let hetero-inline = deg == 1 and not _is-carbon(atom) + if hetero-inline { + let ni = first-neighbor(parent) + if ni == none { return (px, py) } + let nb = sp.layout.atoms.at(ni) + let (nbx, nby) = _rot(nb.pos.x, nb.pos.y, sp.rotation) + let dx = nbx - pxr + let dy = nby - pyr + let pad-u = 1pt / cs + let sym-w = content-width(sym-text) + let h-w = content-width(atom-label("H")) + let (sym-anchor, h-at) = if calc.abs(dx) >= calc.abs(dy) { + if dx > 0 { ("east", "west") } else { ("west", "east") } + } else if dy > 0 { + ("north", "east") + } else { + ("south", "east") + } + + let sym-center = if sym-anchor == "east" { + (px - pad-u - content-width(if h-at == "west" { charge } else { [] }) - sym-w / 2, py) + } else if sym-anchor == "west" { + (px + pad-u + sym-w / 2, py) + } else if sym-anchor == "north" { + (px, py - content-height(sym-text) / 2) + } else { + (px, py + content-height(sym-text) / 2) + } + + if fragment == "sym" { + return sym-center + } + + let hx = if h-at == "west" { + sym-center.at(0) - sym-w / 2 - h-w / 2 + } else { + sym-center.at(0) + sym-w / 2 + h-w / 2 + } + return (hx, sym-center.at(1)) + } + + let stacked-h = deg >= 2 and not _is-carbon(atom) + if stacked-h { + if fragment == "h" { + return (px, py + label-margin * 0.95) + } + let label-content = sym-text + charge + return ( + px - content-width(label-content) / 2 + content-width(sym-text) / 2, + py, + ) + } + + let reverse-inline = if deg != 1 { + false + } else { + let ni = first-neighbor(parent) + if ni == none { + false + } else { + let neighbor = sp.layout.atoms.at(ni) + let (nx, _) = _rot(neighbor.pos.x, neighbor.pos.y, sp.rotation) + nx - pxr > 0.05 + } + } + let label-content = if reverse-inline { + ht + sym-text + charge + } else { + sym-text + ht + charge + } + let left = px - content-width(label-content) / 2 + if fragment == "sym" { + let prefix = if reverse-inline { ht } else { [] } + (left + content-width(prefix) + content-width(sym-text) / 2, py) + } else { + let prefix = if reverse-inline { [] } else { sym-text } + (left + content-width(prefix) + content-width(atom-label("H")) / 2, py) + } + } + + if a.at("virtual_h", default: false) { + let parent = virtual-parent(i) + if parent == none { return base } + return label-fragment-pos(parent, "h") + } + + if not _has-label(a, show-all-h: show-all-h) { return base } + let child = virtual-child(i) + if child == none { return base } + label-fragment-pos(i, "sym") +} + +// Find the bond record joining atoms i and j in a species' layout. +#let _find-bond(sp, i, j) = { + for b in sp.layout.bonds { + if (b.from == i and b.to == j) or (b.from == j and b.to == i) { return b } + } + panic("no bond between atoms " + str(i) + " and " + str(j)) +} + +// Resolve a reference dictionary to an absolute canvas coordinate. +// `species` is the list of placed species records; `lp-offset` is the radial +// distance of a lone pair from its atom (in bond-length units). +#let _resolve(ref, species, lp-offset) = { + let nudge(p) = { + let o = ref.at("offset", default: (0, 0)) + (p.at(0) + o.at(0), p.at(1) + o.at(1)) + } + let kind = ref.__ref__ + if kind == "atom" { + nudge(_atom-pos(species.at(ref.species), ref.index)) + } else if kind == "bond" { + let sp = species.at(ref.species) + let pa = _atom-pos(sp, ref.i) + let pb = _atom-pos(sp, ref.j) + nudge(((pa.at(0) + pb.at(0)) / 2, (pa.at(1) + pb.at(1)) / 2)) + } else if kind == "lp" { + let sp = species.at(ref.species) + let a = sp.layout.atoms.at(ref.atom) + let dirs = a.at("lone_pair_dirs", default: ()) + let base = _atom-pos(sp, ref.atom) + if dirs.len() == 0 { + nudge(base) + } else { + let d = dirs.at(calc.min(ref.pair, dirs.len() - 1)) + let (dx, dy) = _rot(d.x, d.y, sp.rotation) + nudge((base.at(0) + dx * lp-offset, base.at(1) + dy * lp-offset)) + } + } else if kind == "species" { + // Bounding-box center of an opaque item; edge selection happens at draw time. + let sp = species.at(ref.index) + nudge(sp.origin) + } else { + panic("unknown reference kind: " + kind) + } +} + +// ── Annotation constructors (consumed by smiles() and reaction()) ─────────────── + +/// References an atom by index. `atom(i)` inside `smiles()`, or `atom(s, i)` +/// inside `reaction()` where `s` is the species (molecule) index. `offset` nudges +/// the point in bond-length units. +#let atom(..a) = { + let p = a.pos() + let (s, i) = if p.len() == 1 { (0, p.at(0)) } else { (p.at(0), p.at(1)) } + (__ref__: "atom", species: s, index: i, offset: a.named().at("offset", default: (0, 0))) +} + +/// References the midpoint of the bond between two atoms: `bond(i, j)` or +/// `bond(s, i, j)`. +#let bond(..a) = { + let p = a.pos() + let (s, i, j) = if p.len() == 2 { (0, p.at(0), p.at(1)) } else { (p.at(0), p.at(1), p.at(2)) } + (__ref__: "bond", species: s, i: i, j: j, offset: a.named().at("offset", default: (0, 0))) +} + +/// References a lone pair on an atom: `lp(i)` or `lp(s, i)`, with `pair:` selecting +/// which pair (default 0) when an atom carries several. +#let lp(..a) = { + let p = a.pos() + let (s, at) = if p.len() == 1 { (0, p.at(0)) } else { (p.at(0), p.at(1)) } + ( + __ref__: "lp", + species: s, + atom: at, + pair: a.named().at("pair", default: 0), + offset: a.named().at("offset", default: (0, 0)), + ) +} + +/// References a whole placed species (e.g. a `ce()` formula) by its index, snapping +/// to its bounding-box edge. Used when no interior atom is addressable. +#let species(k, offset: (0, 0)) = (__ref__: "species", index: k, offset: offset) + +/// A curly electron-pushing arrow between two references. +/// +/// - from (dictionary): source reference — `lp()`, `bond()`, `atom()`, `species()`. +/// - to (dictionary): destination reference. +/// - label (content): optional label drawn at the curve apex. +/// - color (color): arrow color. Default: red. +/// - bend (str): "left", "right", or none (straight). Which way the curve bows. +/// - angle (angle): how strongly the curve bows. Default: 15deg. +/// - half (bool): draw a half (fishhook) arrowhead for single-electron flow. +/// -> dictionary (consumed by smiles()/reaction()) +#let arrow(from: none, to: none, label: none, color: red, bend: "left", angle: 15deg, half: false) = ( + __arrow__: true, + from: from, + to: to, + label: label, + color: color, + bend: bend, + angle: angle, + half: half, +) + +/// Highlights an atom (disk) or bond (capsule) behind the structure. +/// +/// - ref (dictionary/array): `atom(...)` or `bond(...)` reference, or an array of +/// references to highlight together. +/// - fill (color): highlight color. Default: a soft yellow. +/// - stroke (none/stroke): outline of an atom highlight. Default: none. +/// - radius (auto/float): atom-highlight radius in bond-length units. +/// - include-atoms (bool): for bond highlights, also shade both endpoint atoms. +/// Default: false. +/// -> dictionary (consumed by smiles()/reaction()) +#let highlight(ref, fill: rgb("#FFE45C"), stroke: none, radius: auto, include-atoms: false) = ( + __highlight__: true, + ref: ref, + fill: fill, + stroke: stroke, + radius: radius, + include-atoms: include-atoms, +) + +// ── Annotation drawing ────────────────────────────────────────────────────────── + +// Endpoint coordinate for an arrow; species references snap to the box edge facing +// `toward`. +#let _endpoint(ref, specs, cfg, toward) = { + let p = _resolve(ref, specs, cfg.lp-offset) + if ref.__ref__ != "species" { return p } + let sp = specs.at(ref.index) + let (w, h) = sp.size + let dx = toward.at(0) - p.at(0) + let dy = toward.at(1) - p.at(1) + if dx == 0 and dy == 0 { return p } + let tx = if dx != 0 { (w / 2) / calc.abs(dx) } else { 1e9 } + let ty = if dy != 0 { (h / 2) / calc.abs(dy) } else { 1e9 } + let t = calc.min(tx, ty) + (p.at(0) + dx * t, p.at(1) + dy * t) +} + +#let _draw-highlight(h, specs, cfg) = { + import cetz.draw: * + let refs = if type(h.ref) == array { h.ref } else { (h.ref,) } + for ref in refs { + if ref.__ref__ == "bond" { + let sp = specs.at(ref.species) + let pa = _atom-pos(sp, ref.i) + let pb = _atom-pos(sp, ref.j) + let dx = pb.at(0) - pa.at(0) + let dy = pb.at(1) - pa.at(1) + let len = calc.max(1e-6, calc.sqrt(dx * dx + dy * dy)) + let trim = if h.include-atoms { 0.0 } else { calc.min(cfg.bond-trim, len * 0.45) } + let ux = dx / len + let uy = dy / len + line( + (pa.at(0) + ux * trim, pa.at(1) + uy * trim), + (pb.at(0) - ux * trim, pb.at(1) - uy * trim), + stroke: (paint: h.fill, thickness: cfg.bond-thickness, cap: "round"), + ) + if h.include-atoms { + let r = if h.radius == auto { cfg.atom-radius } else { h.radius } + circle(pa, radius: r, fill: h.fill, stroke: h.stroke) + circle(pb, radius: r, fill: h.fill, stroke: h.stroke) + } + } else { + let p = _resolve(ref, specs, cfg.lp-offset) + let sp = specs.at(ref.species) + let atom = sp.layout.atoms.at(ref.index) + let abbrev = atom.at("abbrev", default: "") + if ref.__ref__ == "atom" and abbrev != "" { + let cs = sp.at("canvas-scale", default: 30pt) + let fs = sp.at("actual-font-size", default: 11pt) + let font = sp.at("font", default: "New Computer Modern") + let label = text(size: fs, font: font, style: "normal", weight: "regular", abbrev) + let w = measure(label).width / cs + let h-units = measure(label).height / cs + let pad-x = calc.max(0.08, fs / cs * 0.16) + let thick = cs * calc.max(h-units + fs / cs * 0.55, cfg.atom-radius * 1.7) + line( + (p.at(0) - w / 2 - pad-x, p.at(1)), + (p.at(0) + w / 2 + pad-x, p.at(1)), + stroke: (paint: h.fill, thickness: thick, cap: "round"), + ) + } else { + let r = if h.radius == auto { cfg.atom-radius } else { h.radius } + circle(p, radius: r, fill: h.fill, stroke: h.stroke) + } + } + } +} + +#let _draw-arrow(a, specs, cfg) = { + import cetz.draw: * + let raw0 = _resolve(a.from, specs, cfg.lp-offset) + let raw1 = _resolve(a.to, specs, cfg.lp-offset) + let p0 = _endpoint(a.from, specs, cfg, raw1) + let p1 = _endpoint(a.to, specs, cfg, raw0) + + let dx = p1.at(0) - p0.at(0) + let dy = p1.at(1) - p0.at(1) + let len = calc.max(1e-6, calc.sqrt(dx * dx + dy * dy)) + let ux = dx / len + let uy = dy / len + + // Pull both ends in slightly so the tail clears its source glyph and the head + // stops just short of the target. + let ins = calc.min(cfg.inset, len * 0.3) + let q0 = (p0.at(0) + ux * ins, p0.at(1) + uy * ins) + let q1 = (p1.at(0) - ux * ins, p1.at(1) - uy * ins) + + let sign = if a.bend == "right" { -1.0 } else if a.bend == "left" { 1.0 } else { 0.0 } + let nx = -uy + let ny = ux + let mx = (q0.at(0) + q1.at(0)) / 2 + let my = (q0.at(1) + q1.at(1)) / 2 + let mag = (len / 2) * calc.tan(a.angle) * sign + let apex = (mx + nx * mag, my + ny * mag) + + let mk = (end: ">", fill: a.color, scale: cfg.arrow-scale, harpoon: a.half) + let strk = (paint: a.color, thickness: cfg.arrow-thickness) + if sign == 0 { + line(q0, q1, stroke: strk, mark: mk) + } else { + bezier-through(q0, apex, q1, stroke: strk, mark: mk) + } + + if a.label != none { + let g = if sign == 0 { cfg.label-gap } else { mag + sign * cfg.label-gap } + content( + (mx + nx * g, my + ny * g), + text(size: cfg.label-size, fill: a.color, a.label), + anchor: "center", + ) + } +} + +// Annotation styling derived from the shared canvas scale and font size. +#let _annotation-cfg(canvas-scale, font-size, scale) = ( + lp-offset: calc.max(0.1, font-size / canvas-scale * 0.6), + atom-radius: calc.max(0.12, font-size / canvas-scale * 0.5), + bond-thickness: canvas-scale * 0.42, + bond-trim: calc.max(0.42, font-size / canvas-scale * 0.75), + arrow-thickness: calc.max(0.7pt, 1.0pt * scale), + arrow-scale: scale, + label-size: font-size * 0.92, + inset: 0.16, + label-gap: 0.34, +) + +// ── SMILES renderer ───────────────────────────────────────────────────────────── + +/// Renders a SMILES string as a 2D skeletal molecular diagram. +/// +/// - smiles-str (str): A valid SMILES string, e.g. "C1=CC=CC=C1" for benzene. +/// Use Kekulé notation for aromatic rings (C not c) until aromatic support lands. +/// - scale (float): Balanced scale for bond length, atom labels, and bond stroke. +/// Explicit bond-length, font-size, or bond-stroke values override it. +/// Default: 1.0. +/// - bond-length (float): Bond length scale factor; 1.0 = 30 pt per bond. +/// - font-size (length): Font size for atom labels. +/// - font (str): Font for atom labels. Default: "New Computer Modern". +/// - bond-stroke (length): Bond stroke width. +/// - color (bool): Apply Jmol CPK atom colors. Default: true. +/// - rotation (angle): Rotate the molecule by this angle. Atom labels stay upright. +/// Example: rotation: 90deg. Default: 0deg. +/// - show-all-h (bool): Show computed implicit hydrogens on all atoms, +/// including carbon. Default: false. +/// - lone-pairs (none / "dots" / "lines"): Draw non-bonding electron pairs on +/// skeletal atom labels. Default: none. +/// - atom-colors (dictionary): Color overrides taking priority over the CPK palette +/// and inline `{label|style}` styles. See documentation for the two key forms. +/// - show-indices (bool): Stamp each atom's writing-order index on the diagram, as a +/// development aid for writing atom()/bond()/lp() references. Default: false. +/// - ..annotations: Any number of arrow() / highlight() items referencing atoms of +/// this molecule (single-index form, e.g. atom(2)). +/// -> content +#let smiles( + smiles-str, + scale: 1.0, + bond-length: none, + font-size: none, + font: "New Computer Modern", + bond-stroke: none, + color: true, + rotation: 0deg, + show-all-h: false, + lone-pairs: none, + atom-colors: (:), + show-indices: false, + ..annotations +) = context { + let layout = _layout(smiles-str) + let canvas-scale = _canvas-scale(scale, bond-length) + let actual-font-size = if font-size == none { 11pt * scale } else { font-size } + let ann = annotations.pos() + let specs = (( + kind: "mol", + layout: layout, + rotation: rotation, + origin: (0, 0), + size: (layout.bbox_width, layout.bbox_height), + canvas-scale: canvas-scale, + actual-font-size: actual-font-size, + font: font, + show-all-h: show-all-h, + ),) + let cfg = _annotation-cfg(canvas-scale, actual-font-size, scale) + let the-scale = scale // cetz.draw `scale` shadows the argument inside the canvas + + cetz.canvas(length: canvas-scale, { + import cetz.draw: * + for h in ann { + if type(h) == dictionary and h.at("__highlight__", default: false) { + _draw-highlight(h, specs, cfg) + } + } + _draw-molecule( + layout, + scale: the-scale, + bond-length: bond-length, + font-size: font-size, + font: font, + bond-stroke: bond-stroke, + color: color, + rotation: rotation, + show-all-h: show-all-h, + lone-pairs: lone-pairs, + atom-colors: atom-colors, + show-indices: show-indices, + ) + for ar in ann { + if type(ar) == dictionary and ar.at("__arrow__", default: false) { + _draw-arrow(ar, specs, cfg) + } + } + }) +} + +// Capture before `reaction` shadows the name with its own `scale` parameter. +#let _typst-scale = scale + +// ── Reaction scheme helpers ─────────────────────────────────────────────────── + +/// Creates a reaction arrow for use inside #reaction(). +/// +/// - above (content): Label above a horizontal arrow / to the right of a vertical one. +/// - below (content): Label below a horizontal arrow / to the left of a vertical one. +/// - dir (str): Arrow direction — "right" (default), "left", "down", or "up". +/// - kind (str): Arrow style — "single" (default), "equilibrium", or "equilibrium-filled". +/// -> dictionary (consumed by #reaction) +#let rxn-arrow(above: none, below: none, dir: "right", kind: "single") = ( + __rxn_arrow__: true, + above: above, + below: below, + dir: dir, + kind: kind, +) + +// Render a horizontal reaction arrow. +#let _horiz-arrow(above, below, dir, kind) = { + let arrow-parts = () + if above != none { arrow-parts.push(align(center, text(size: 8pt, above))) } + let (sx, ex) = if dir == "left" { (52, 0) } else { (0, 52) } + arrow-parts.push(cetz.canvas(length: 1pt, { + import cetz.draw: * + if kind == "single" { + line((sx, 0), (ex, 0), mark: (end: ">", fill: black, size: 5), stroke: 0.8pt + black) + } else if kind == "equilibrium" or kind == "equilibrium-filled" { + let sign = if ex > sx { 1 } else { -1 } + let head-len = 7 + let head-rise = 3.5 + if kind == "equilibrium-filled" { + let top-base = ex - sign * head-len + line((sx, 2.2), (ex, 2.2), stroke: 0.8pt + black) + line( + (ex, 2.2), (top-base, 2.2), (top-base, 2.2 + head-rise), + close: true, fill: black, stroke: none, + ) + } else { + line((sx, 2.2), (ex, 2.2), stroke: 0.8pt + black) + line((ex, 2.2), (ex - sign * head-len, 2.2 + head-rise), stroke: 0.8pt + black) + } + if kind == "equilibrium-filled" { + let bottom-base = sx + sign * head-len + line((ex, -2.2), (sx, -2.2), stroke: 0.8pt + black) + line( + (sx, -2.2), (bottom-base, -2.2), (bottom-base, -2.2 - head-rise), + close: true, fill: black, stroke: none, + ) + } else { + line((ex, -2.2), (sx, -2.2), stroke: 0.8pt + black) + line((sx, -2.2), (sx + sign * head-len, -2.2 - head-rise), stroke: 0.8pt + black) + } + } else { + panic("rxn-arrow kind must be \"single\", \"equilibrium\", or \"equilibrium-filled\"") + } + })) + if below != none { arrow-parts.push(align(center, text(size: 8pt, below))) } + align(center + horizon, stack(spacing: 3pt, ..arrow-parts)) +} + +// Render a vertical reaction arrow. `above` is shown to the right, `below` to the left. +#let _vert-arrow(above, below, dir, kind) = { + let (from-y, to-y) = if dir == "up" { (0, 52) } else { (52, 0) } + let arrow-canvas = cetz.canvas(length: 1pt, { + import cetz.draw: * + if kind == "single" { + line((0, from-y), (0, to-y), mark: (end: ">", fill: black, size: 5), stroke: 0.8pt + black) + } else if kind == "equilibrium" or kind == "equilibrium-filled" { + let sign = if to-y > from-y { 1 } else { -1 } + let head-len = 7 + let head-rise = 3.5 + if kind == "equilibrium-filled" { + let left-base = to-y - sign * head-len + line((-2.2, from-y), (-2.2, to-y), stroke: 0.8pt + black) + line( + (-2.2, to-y), (-2.2, left-base), (-2.2 - head-rise, left-base), + close: true, fill: black, stroke: none, + ) + } else { + line((-2.2, from-y), (-2.2, to-y), stroke: 0.8pt + black) + line((-2.2, to-y), (-2.2 - head-rise, to-y - sign * head-len), stroke: 0.8pt + black) + } + if kind == "equilibrium-filled" { + let right-base = from-y + sign * head-len + line((2.2, to-y), (2.2, from-y), stroke: 0.8pt + black) + line( + (2.2, from-y), (2.2, right-base), (2.2 + head-rise, right-base), + close: true, fill: black, stroke: none, + ) + } else { + line((2.2, to-y), (2.2, from-y), stroke: 0.8pt + black) + line((2.2, from-y), (2.2 + head-rise, from-y + sign * head-len), stroke: 0.8pt + black) + } + } else { + panic("rxn-arrow kind must be \"single\", \"equilibrium\", or \"equilibrium-filled\"") + } + }) + if above == none and below == none { + align(center + horizon, arrow-canvas) + } else { + grid( + columns: (auto, auto, auto), + column-gutter: 4pt, + align: center + horizon, + if below != none { text(size: 8pt, below) } else { [] }, + arrow-canvas, + if above != none { text(size: 8pt, above) } else { [] }, + ) + } +} + +/// A reaction-scheme item: a molecule or any content, with an optional label and +/// position offset. Consumed by #reaction(). +/// +/// - spec (str / content): A SMILES string (rendered by #reaction with addressable +/// atoms) or any content (e.g. ce(...), smiles(...), text — an opaque block). +/// - label (content): Optional label shown below. Default: none. +/// - offset (array): (dx, dy) nudge in bond-length units. A non-zero offset, or any +/// curly arrow()/highlight() in the same #reaction, switches it to mechanism mode. +/// - ..opts: Molecule drawing options used when `spec` is a string. In mechanism +/// mode, use `reaction(scale: ...)` for bond length; per-molecule options control +/// labels, strokes, colors, rotation, hydrogens, lone pairs, and index overlays. +/// -> dictionary (consumed by #reaction) +#let mol(spec, label: none, offset: (0, 0), ..opts) = ( + __mol__: true, + spec: spec, + label: label, + offset: offset, + opts: opts.named(), +) + +// Render a mol() item to standalone content (scheme/grid path). +#let _render-mol(m, show-indices-default: false) = { + let opts = m.opts + if type(m.spec) == str and not ("show-indices" in opts) { + opts.insert("show-indices", show-indices-default) + } + let body = if type(m.spec) == str { smiles(m.spec, ..opts) } else { m.spec } + if m.label == none { body } else { stack(spacing: 4pt, body, align(center, m.label)) } +} + +/// Lays out a reaction scheme or an electron-pushing mechanism. +/// +/// Items are any mix of mol(), content (smiles(), ce(), text…), rxn-arrow() +/// (straight reaction arrows) and — for mechanisms — arrow() (curly electron +/// arrows) and highlight() items. +/// +/// Two modes are detected automatically: +/// - Scheme (default): no curly arrow()/highlight() and no mol(offset: …). Items are +/// packed in a grid; rxn-arrow(dir: "right"|"left"|"down"|"up") can wrap the scheme +/// across the page. This path is unchanged from earlier versions. +/// - Mechanism: any curly arrow()/highlight(), or any mol(offset: …). Species are +/// placed in one shared canvas (left to right, each nudged by its offset) so curly +/// arrows can reference atoms across species. References are atom(s, i), +/// bond(s, i, j), lp(s, i) and species(k), where s/k count mol()/content items in +/// written order (rxn-arrows and annotations are not counted). +/// +/// - gap-h (length): Horizontal gap between grid items (scheme mode). Default: 1.5em. +/// - gap-v (length): Vertical gap between grid items (scheme mode). Default: 1.5em. +/// - scale (float): Uniform scale. In mechanism mode it sets the canvas bond length. +/// - breakable (bool): Whether the block may split across pages. Default: false. +/// - show-indices (bool): Default index overlay for string SMILES molecules in +/// this reaction. Individual mol(..., show-indices: ...) calls can override it. +/// -> content +#let reaction(gap-h: 1.5em, gap-v: 1.5em, scale: 1.0, breakable: false, show-indices: false, ..items) = { + let steps = items.pos() + let is-rxn-arrow(it) = type(it) == dictionary and it.at("__rxn_arrow__", default: false) + let is-curly(it) = type(it) == dictionary and it.at("__arrow__", default: false) + let is-highlight(it) = type(it) == dictionary and it.at("__highlight__", default: false) + let is-mol(it) = type(it) == dictionary and it.at("__mol__", default: false) + + let mechanism = steps.any(it => + is-curly(it) or is-highlight(it) or (is-mol(it) and it.offset != (0, 0)) + ) + + if not mechanism { + // ── Scheme (grid) path ────────────────────────────────────────────────── + // Phase 1: assign each item a (grid-row, grid-col) position. + // Grid mapping: mol at (lr,lc) → grid (2*lr, 2*lc); arrows slot into gaps. + let lr = 0 + let lc = 0 + let placed = () + + for item in steps { + if is-rxn-arrow(item) { + let dir = item.at("dir", default: "right") + let (gr, gc) = if dir == "right" { (2 * lr, 2 * lc - 1) } + else if dir == "left" { (2 * lr, 2 * lc - 3) } + else if dir == "down" { (2 * lr + 1, 2 * lc - 2) } + else { (2 * lr - 1, 2 * lc - 2) } + placed.push((gr: gr, gc: gc, kind: "arrow", data: item)) + if dir == "left" { lc -= 2 } + else if dir == "down" { lr += 1; lc -= 1 } + else if dir == "up" { lr -= 1; lc -= 1 } + } else { + placed.push((gr: 2 * lr, gc: 2 * lc, kind: "mol", data: item)) + lc += 1 + } + } + + if placed.len() == 0 { return [] } + + let min-gr = placed.fold(placed.first().gr, (m, c) => calc.min(m, c.gr)) + let min-gc = placed.fold(placed.first().gc, (m, c) => calc.min(m, c.gc)) + let max-gr = placed.fold(placed.first().gr, (m, c) => calc.max(m, c.gr)) + let max-gc = placed.fold(placed.first().gc, (m, c) => calc.max(m, c.gc)) + + let n-rows = max-gr - min-gr + 1 + let n-cols = max-gc - min-gc + 1 + + let lookup = (:) + for p in placed { + lookup.insert(str(p.gr - min-gr) + "," + str(p.gc - min-gc), p) + } + + let flat-cells = () + for gr in range(n-rows) { + for gc in range(n-cols) { + let p = lookup.at(str(gr) + "," + str(gc), default: none) + if p == none { + flat-cells.push([]) + } else if p.kind == "mol" { + let c = if is-mol(p.data) { _render-mol(p.data, show-indices-default: show-indices) } else { p.data } + flat-cells.push(align(center + horizon, c)) + } else { + let item = p.data + let d = item.at("dir", default: "right") + let k = item.at("kind", default: "single") + flat-cells.push( + if d == "right" or d == "left" { _horiz-arrow(item.above, item.below, d, k) } + else { _vert-arrow(item.above, item.below, d, k) } + ) + } + } + } + + let result = grid( + columns: (auto,) * n-cols, + rows: (auto,) * n-rows, + column-gutter: gap-h / 2, + row-gutter: gap-v / 2, + align: center + horizon, + ..flat-cells, + ) + + let scaled = if scale == 1.0 { + result + } else { + _typst-scale(x: scale * 100%, y: scale * 100%, reflow: true, result) + } + + block(breakable: breakable, scaled) + } else { + // ── Mechanism (shared canvas) path ────────────────────────────────────── + let canvas-scale = scale * 30pt + let font-size = 11pt * scale + let cfg = _annotation-cfg(canvas-scale, font-size, scale) + let the-scale = scale // cetz.draw `scale` shadows the argument inside the canvas + let gap = 1.0 // bond-length units between species + + context { + let specs = () // referenceable items (mol/content), in species-index order + let flow = () // positioned but non-referenceable content (rxn-arrows) + let annotations = () // curly arrows and highlights + let cursor = 0.0 + + for it in steps { + if is-curly(it) or is-highlight(it) { + annotations.push(it) + } else if is-rxn-arrow(it) { + let body = if it.dir == "right" or it.dir == "left" { + _horiz-arrow(it.above, it.below, it.dir, it.kind) + } else { + _vert-arrow(it.above, it.below, it.dir, it.kind) + } + let w = measure(body).width / canvas-scale + flow.push((body: body, origin: (cursor + w / 2, 0))) + cursor += w + gap + } else { + let m = if is-mol(it) { + it + } else { + (__mol__: true, spec: it, label: none, offset: (0, 0), opts: (:)) + } + if type(m.spec) == str { + let lay = _layout(m.spec) + let w = lay.bbox_width + let h = lay.bbox_height + let mol-fs = m.opts.at("font-size", default: none) + let mol-actual-fs = if mol-fs == none { 11pt * scale } else { mol-fs } + specs.push(( + kind: "mol-smiles", + layout: lay, + rotation: m.opts.at("rotation", default: 0deg), + origin: (cursor + w / 2 + m.offset.at(0), m.offset.at(1)), + size: (w, h), + label: m.label, + opts: m.opts, + canvas-scale: canvas-scale, + actual-font-size: mol-actual-fs, + font: m.opts.at("font", default: "New Computer Modern"), + show-all-h: m.opts.at("show-all-h", default: false), + )) + cursor += w + gap + } else { + let meas = measure(m.spec) + let w = meas.width / canvas-scale + let h = meas.height / canvas-scale + specs.push(( + kind: "content", + body: m.spec, + rotation: 0deg, + origin: (cursor + w / 2 + m.offset.at(0), m.offset.at(1)), + size: (w, h), + label: m.label, + )) + cursor += w + gap + } + } + } + + let canvas = cetz.canvas(length: canvas-scale, { + import cetz.draw: * + + for a in annotations { + if a.at("__highlight__", default: false) { _draw-highlight(a, specs, cfg) } + } + + for sp-idx in range(specs.len()) { + let sp = specs.at(sp-idx) + if sp.kind == "mol-smiles" { + group({ + translate((sp.origin.at(0), sp.origin.at(1))) + _draw-molecule( + sp.layout, + scale: the-scale, + font-size: sp.opts.at("font-size", default: none), + font: sp.opts.at("font", default: "New Computer Modern"), + bond-stroke: sp.opts.at("bond-stroke", default: none), + color: sp.opts.at("color", default: true), + rotation: sp.rotation, + show-all-h: sp.opts.at("show-all-h", default: false), + lone-pairs: sp.opts.at("lone-pairs", default: none), + atom-colors: sp.opts.at("atom-colors", default: (:)), + show-indices: sp.opts.at("show-indices", default: show-indices), + index-prefix: "species-" + str(sp-idx) + "-", + ) + }) + } else { + content((sp.origin.at(0), sp.origin.at(1)), sp.body, anchor: "center") + } + } + + for f in flow { + content((f.origin.at(0), f.origin.at(1)), f.body, anchor: "center") + } + + for sp in specs { + if sp.label != none { + content( + (sp.origin.at(0), sp.origin.at(1) - sp.size.at(1) / 2 - 0.34), + sp.label, + anchor: "north", + ) + } + } + + for a in annotations { + if a.at("__arrow__", default: false) { _draw-arrow(a, specs, cfg) } + } + }) + + block(breakable: breakable, canvas) + } + } +} + +/// Wraps content in drawn square brackets, e.g. for a transition state or reactive +/// intermediate. `sup` / `sub` are typeset at the top-right / bottom-right (a charge, +/// a ‡ for a transition state, …). +/// +/// - body (content): The content to enclose. +/// - sup (content): Optional superscript outside the right bracket. Default: none. +/// - sub (content): Optional subscript outside the right bracket. Default: none. +/// - stroke (stroke): Bracket stroke. Default: 0.6pt black. +/// - gap (length): Padding between the brackets and the body. Default: 0.3em. +/// -> content +#let brackets(body, sup: none, sub: none, stroke: 0.6pt + black, gap: 0.3em) = context { + let body-height = measure(body).height + let gp = gap.to-absolute() + let tk = (0.18em).to-absolute() + let total = body-height + 2 * gp + let bstroke = stroke // cetz.draw exports `stroke`, which would shadow the argument + let bracket(left) = { + let dir = if left { 1 } else { -1 } + box(height: total, cetz.canvas(length: 1pt, { + import cetz.draw: * + let hp = total / 1pt + let tp = tk / 1pt + line((dir * tp, 0), (0, 0), (0, hp), (dir * tp, hp), stroke: bstroke) + })) + } + // Stack the marks against the closing bracket: sup pinned to the top corner, + // sub to the bottom, by filling the bracket height. + let supsub = if sup == none and sub == none { + none + } else { + box(height: total, stack( + dir: ttb, + spacing: 1fr, + if sup != none { box(text(size: 0.85em, sup)) } else { box() }, + if sub != none { box(text(size: 0.85em, sub)) } else { box() }, + )) + } + box(baseline: 50% + 0.3em)[#bracket(true)#h(gp)#box(baseline: 50% - body-height / 2, body)#h(gp)#bracket(false)#if supsub != none { h(0.12em); supsub }] +} diff --git a/packages/preview/typed-smiles/0.4.2/typst.toml b/packages/preview/typed-smiles/0.4.2/typst.toml new file mode 100644 index 0000000000..7477686398 --- /dev/null +++ b/packages/preview/typed-smiles/0.4.2/typst.toml @@ -0,0 +1,11 @@ +[package] +name = "typed-smiles" +version = "0.4.2" +entrypoint = "src/lib.typ" +authors = ["Geronimo Castano"] +license = "MIT" +description = "Render SMILES strings as molecular diagrams." +repository = "https://github.com/GeronimoCastano/typed-smiles" +keywords = ["chemistry", "smiles", "molecules", "cheminformatics"] +categories = ["visualization"] +exclude = ["assets/readme/*.png"]