From 5a352a8cb6e0a9ea26208236466f5cf016f5f109 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:07:03 +0100 Subject: [PATCH] Publish sanitized mirror from depict@33d047b2b3d25cdef219e3041ac3aeb754e6b92a --- .github/workflows/maven.yml | 22 +- .gitignore | 5 +- README.md | 23 +- cdkdepict-lib/pom.xml | 15 +- .../openscience/cdk/app/DepictController.java | 1433 +++++++++++++++-- .../java/org/openscience/cdk/app/MolOp.java | 346 ++-- .../org/openscience/cdk/app/group_abbr.smi | 6 +- .../org/openscience/cdk/app/reagent_abbr.smi | 3 - .../cdk/app/DepictControllerTest.java | 196 ++- .../cdk/app/ReactionCenterDetectionTest.java | 169 ++ cdkdepict-webapp/pom.xml | 28 +- .../org/openscience/cdk/app/Application.java | 9 +- .../main/webapp/WEB-INF/static/css/depict.css | 45 +- .../main/webapp/WEB-INF/static/depict.html | 28 +- .../main/webapp/WEB-INF/static/js/depict.js | 15 +- .../src/main/webapp/WEB-INF/static/js/map.js | 188 +++ .../main/webapp/WEB-INF/static/js/react.js | 156 ++ .../src/main/webapp/WEB-INF/static/map.html | 131 ++ .../src/main/webapp/WEB-INF/static/react.html | 143 ++ .../{web-javaee.xml => web-jakarta.xml} | 8 +- .../src/main/webapp/WEB-INF/web.xml | 9 +- docker/Dockerfile | 7 +- .../{Dockerfile.javaee => Dockerfile.jakarta} | 7 +- docker/README.md | 10 +- docs/RDT_CDK_INTEGRATION.md | 239 +++ docs/depict-endpoint-agent.md | 183 +++ pom.xml | 463 +++--- 27 files changed, 3205 insertions(+), 682 deletions(-) create mode 100644 cdkdepict-lib/src/test/java/org/openscience/cdk/app/ReactionCenterDetectionTest.java create mode 100644 cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/map.js create mode 100644 cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/react.js create mode 100644 cdkdepict-webapp/src/main/webapp/WEB-INF/static/map.html create mode 100644 cdkdepict-webapp/src/main/webapp/WEB-INF/static/react.html rename cdkdepict-webapp/src/main/webapp/WEB-INF/{web-javaee.xml => web-jakarta.xml} (86%) rename docker/{Dockerfile.javaee => Dockerfile.jakarta} (80%) create mode 100644 docs/RDT_CDK_INTEGRATION.md create mode 100644 docs/depict-endpoint-agent.md diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0ff525a..a90fd18 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -7,19 +7,23 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-24.04 - strategy: - matrix: - # test against latest update of each major Java version: - java: [ 17, 21, 25 ] - name: Java ${{ matrix.java }} + runs-on: ubuntu-22.04 + env: + # Pinned for reproducible builds (ReactionDecoder is not on Maven Central). + RDT_REF: 0d6632251108e7a8f625d15c60607a136093c4d8 + name: Java 25 steps: - uses: actions/checkout@v4 - - name: Setup java + - name: Setup Java 25 uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: ${{ matrix.java }} + distribution: 'temurin' + java-version: '25' cache: 'maven' + - name: Build and install ReactionDecoder (RDT) into local Maven repo + run: | + git clone https://github.com/asad/ReactionDecoder.git "${RUNNER_TEMP}/ReactionDecoder" + git -C "${RUNNER_TEMP}/ReactionDecoder" checkout --detach "${RDT_REF}" + mvn -B -f "${RUNNER_TEMP}/ReactionDecoder/pom.xml" clean install -DskipTests=true - name: Build with Maven run: mvn -B clean test javadoc:javadoc package diff --git a/.gitignore b/.gitignore index 82de2b5..d7fd84b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ .settings .classpath target - - +.idea +.DS_Store +.vscode # Mobile Tools for Java (J2ME) .mtj.tmp/ diff --git a/README.md b/README.md index 08875a1..43ece61 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,25 @@ A web application for generating chemical structure depictions from SMILES. ## [https://www.simolecule.com/cdkdepict](http://www.simolecule.com/cdkdepict) +## Atom and bond tagging + +The `/depict/{style}/{fmt}` endpoint now understands an optional `atomlists` +query parameter that can be used to colour specific atoms and their connecting +bonds. Provide the parameter as a semicolon (or pipe) separated list of atom +index collections, for example: + +``` +atomlists=0,1,2;#ff8800:5,6 +``` + +Each sublist receives its own highlight colour; when a colour is not provided, +the server cycles through the default palette that is already used for atom-map +visualisation. Any bond between atoms in the same sublist is highlighted using +the same colour. The indices refer to the zero-based ordering of atoms in the +depicted molecule. Colours can be supplied in hexadecimal form (for example +`#ff8800` or `0xff8800`) or as comma-separated RGB(A) components such as +`255,136,0`. + ## Docker An image is available on DockerHub, [https://hub.docker.com/r/simolecule/cdkdepict/](https://hub.docker.com/r/simolecule/cdkdepict/). To launch a CDK Depict web serivce running on 8081: @@ -17,7 +36,7 @@ $ docker run -p 8081:8080 simolecule/cdkdepict:latest You can download prebuilt release from GitHub, this is a runnable WAR and will launch a standalone server using Spring Boot: - * [`cdkdepict.war`](https://github.com/cdk/depict/releases/download/1.14/cdkdepict.war) + * [`cdkdepict.war`](https://github.com/cdk/depict/releases/download/1.13/cdkdepict.war) #### Build @@ -30,7 +49,7 @@ target directory. The WAR file can be deployed to an application server (e.g. TomCat, Jetty, GlassFish, JBOSS) whilst the JAR launches it's own embedded server. ``` -$ target/cdkdepict-webapp-1.14.war +$ target/cdkdepict-1.10.war ``` #### Standalone Bootable App diff --git a/cdkdepict-lib/pom.xml b/cdkdepict-lib/pom.xml index 2dca309..5368002 100644 --- a/cdkdepict-lib/pom.xml +++ b/cdkdepict-lib/pom.xml @@ -5,10 +5,9 @@ cdkdepict org.openscience.cdk - 1.15 + 1.14 4.0.0 - cdkdepict-lib cdkdepict-lib @@ -44,12 +43,22 @@ com.simolecule.centres centres-cdk - 1.4 + 1.2.1 + + + com.bioinceptionlabs + rdt + ${rdt.version} org.slf4j slf4j-api 1.7.36 + + com.google.guava + guava + 33.1.0-jre + diff --git a/cdkdepict-lib/src/main/java/org/openscience/cdk/app/DepictController.java b/cdkdepict-lib/src/main/java/org/openscience/cdk/app/DepictController.java index 4b80ca8..7a14c36 100644 --- a/cdkdepict-lib/src/main/java/org/openscience/cdk/app/DepictController.java +++ b/cdkdepict-lib/src/main/java/org/openscience/cdk/app/DepictController.java @@ -4,11 +4,14 @@ package org.openscience.cdk.app; +import com.bioinceptionlabs.reactionblast.api.RDT; +import com.bioinceptionlabs.reactionblast.api.ReactionResult; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; import com.simolecule.centres.BaseMol; import com.simolecule.centres.CdkLabeller; import com.simolecule.centres.Descriptor; import org.openscience.cdk.CDKConstants; -import org.openscience.cdk.aromaticity.Aromaticity; import org.openscience.cdk.depict.Abbreviations; import org.openscience.cdk.depict.Depiction; import org.openscience.cdk.depict.DepictionGenerator; @@ -38,7 +41,10 @@ import org.openscience.cdk.sgroup.SgroupKey; import org.openscience.cdk.sgroup.SgroupType; import org.openscience.cdk.silent.SilentChemObjectBuilder; +import org.openscience.cdk.smirks.Smirks; +import org.openscience.cdk.smirks.SmirksTransform; import org.openscience.cdk.smarts.SmartsPattern; +import org.openscience.cdk.smiles.SmilesGenerator; import org.openscience.cdk.smiles.SmilesParser; import org.openscience.cdk.stereo.ExtendedTetrahedral; import org.openscience.cdk.stereo.Octahedral; @@ -46,8 +52,8 @@ import org.openscience.cdk.stereo.Stereocenters; import org.openscience.cdk.stereo.TetrahedralChirality; import org.openscience.cdk.stereo.TrigonalBipyramidal; +import org.openscience.cdk.isomorphism.Transform; import org.openscience.cdk.tools.manipulator.AtomContainerManipulator; -import org.openscience.cdk.tools.manipulator.HydrogenState; import org.openscience.cdk.tools.manipulator.ReactionManipulator; import org.openscience.cdk.tools.manipulator.ReactionSetManipulator; import org.slf4j.LoggerFactory; @@ -70,6 +76,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; @@ -77,6 +84,8 @@ import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -93,32 +102,98 @@ public class DepictController { private final Object lock = new Object(); + static final int WL_ROUNDS = 2; + static final int MAX_REACTION_PRODUCTS = 50; + static final Color REACTION_CHANGE_COLOR = new Color(179, 204, 255); + static final Color REACTION_CHANGE_COLOR_NIGHT = new Color(120, 160, 220); + static final Color REACTION_CHANGE_COLOR_NEON = new Color(0, 160, 200); + + static class InvalidSmirksException extends IllegalArgumentException { + InvalidSmirksException(String message) { + super(message); + } + + InvalidSmirksException(String message, Throwable cause) { + super(message, cause); + } + } + + static class InvalidReactionMappingException extends IllegalArgumentException { + InvalidReactionMappingException(String message) { + super(message); + } + + InvalidReactionMappingException(String message, Throwable cause) { + super(message, cause); + } + } - private Color[] COLORS = new Color[]{ - new Color(0xe6194b), - new Color(0x3cb44b), - new Color(0xffe119), - new Color(0x0082c8), - new Color(0xf58231), - new Color(0x911eb4), - new Color(0x46f0f0), - new Color(0xf032e6), - new Color(0xd2f53c), - new Color(0xfabebe), - new Color(0x008080), - new Color(0xe6beff), - new Color(0xaa6e28), - new Color(0xfffac8), - new Color(0x800000), - new Color(0xaaffc3), - new Color(0x808000), - new Color(0xffd8b1), - new Color(0x000080), - new Color(0x808080), - new Color(0xE3E3E3), - new Color(0x000000) + private final Color[] COLORS = new Color[]{ + new Color(179, 204, 255), + new Color(194, 255, 190), + new Color(255, 166, 166), + new Color(249, 163, 251), + new Color(255, 225, 154), + new Color(227, 227, 227) + }; + private static final Color[] COLORS_NIGHT = new Color[]{ + new Color(120, 160, 220), + new Color(110, 190, 130), + new Color(210, 120, 120), + new Color(200, 120, 200), + new Color(210, 190, 120), + new Color(170, 170, 170) + }; + private static final Color[] COLORS_NEON = new Color[]{ + new Color(0, 160, 200), + new Color(255, 50, 120), + new Color(255, 160, 0), + new Color(180, 60, 255), + new Color(255, 240, 50), + new Color(255, 100, 220) }; + private enum ThemeGroup { DAY, NIGHT, NEON } + + private static ThemeGroup themeGroupOf(String style) { + switch (style == null ? "" : style) { + case "nob": + case "not": + return ThemeGroup.NEON; + case "wot": + case "wcot": + case "cob": + case "wob": + return ThemeGroup.NIGHT; + default: + return ThemeGroup.DAY; + } + } + + private Color[] colmapColorsForStyle(String style) { + switch (themeGroupOf(style)) { + case NEON: return COLORS_NEON; + case NIGHT: return COLORS_NIGHT; + default: return COLORS; + } + } + + private static Color reactionChangeColorForStyle(String style) { + switch (themeGroupOf(style)) { + case NEON: return REACTION_CHANGE_COLOR_NEON; + case NIGHT: return REACTION_CHANGE_COLOR_NIGHT; + default: return REACTION_CHANGE_COLOR; + } + } + + private Color[] highlightColorsForStyle(String style) { + switch (themeGroupOf(style)) { + case NEON: return COLORS_NEON; + case NIGHT: return COLORS_NIGHT; + default: return COLORS; + } + } + private final ExecutorService smartsExecutor = Executors.newFixedThreadPool(4); // chem object builder to create objects with @@ -140,6 +215,7 @@ private enum Param { ALIGNRXNMAP("alignrxnmap", true), ANON("anon", false), SUPRESSH("suppressh", true), + ATOMSUBLISTS("atomlists", ""), ANNOTATE("annotate", "none"), ABBREVIATE("abbr", "reagents"), // rendering param @@ -148,12 +224,11 @@ private enum Param { SHOWTITLE("showtitle", false), ARROW("arw", IReaction.Direction.FORWARD), DATIVE("dat", MolOp.DativeBond.Metals), - DONUTS("dnt", false), - MULTICENTER("mc", MolOp.MulticenterStyle.Provided), ZOOM("zoom", 1.3), RATIO("ratio", 1.1), ROTATE("r", 0), FLIP("f", false), + REVERSE("reverse", false), WIDTH("w", -1), HEIGHT("h", -1), SVGUNITS("svgunits", "mm"); @@ -167,10 +242,17 @@ private enum Param { } public DepictController() throws IOException { + this.groupAbbr.loadFromFile("/org/openscience/cdk/app/group_abbr.smi"); this.agentAbbr.loadFromFile("/org/openscience/cdk/app/reagent_abbr.smi"); this.agentAbbr.loadFromFile("/org/openscience/cdk/app/group_abbr.smi"); - this.agentAbbr.with(Abbreviations.Option.ALLOW_SINGLETON); - this.groupAbbr.loadFromFile("/org/openscience/cdk/app/group_abbr.smi"); + this.agentAbbr + .with(Abbreviations.Option.ALLOW_SINGLETON) + .with(Abbreviations.Option.AUTO_CONTRACT_TERMINAL) + .without(Abbreviations.Option.AUTO_CONTRACT_HETERO); + this.groupAbbr + .without(Abbreviations.Option.ALLOW_SINGLETON) + .with(Abbreviations.Option.AUTO_CONTRACT_TERMINAL) + .without(Abbreviations.Option.AUTO_CONTRACT_HETERO); } private T getParam(Param param, @@ -232,19 +314,44 @@ private boolean getBoolean(Param param, Map params) { } static Color getColor(String color) { + if (color == null) + throw new IllegalArgumentException("Color string must not be null"); + + String trimmed = color.trim(); + if (trimmed.isEmpty()) + throw new IllegalArgumentException("Color string must not be empty"); + + if (trimmed.indexOf(',') >= 0) { + String[] parts = trimmed.split(","); + if (parts.length < 3 || parts.length > 4) + throw new IllegalArgumentException("Expected 3 or 4 comma-separated components"); + int r = parseColorComponent(parts[0].trim()); + int g = parseColorComponent(parts[1].trim()); + int b = parseColorComponent(parts[2].trim()); + int a = parts.length == 4 ? parseColorComponent(parts[3].trim()) : 255; + return new Color(r, g, b, a); + } + int vals[] = new int[]{0, 0, 0, 255}; // r,g,b,a int pos = 0; int beg = 0; - if (color.startsWith("0x")) + if (trimmed.startsWith("0x")) beg = 2; - else if (color.startsWith("#")) + else if (trimmed.startsWith("#")) beg = 1; - for (; pos < 4 && beg + 1 < color.length(); beg += 2) { - vals[pos++] = Integer.parseInt(color.substring(beg, beg + 2), 16); + for (; pos < 4 && beg + 1 < trimmed.length(); beg += 2) { + vals[pos++] = Integer.parseInt(trimmed.substring(beg, beg + 2), 16); } return new Color(vals[0], vals[1], vals[2], vals[3]); } + private static int parseColorComponent(String component) { + int value = Integer.parseInt(component); + if (value < 0 || value > 255) + throw new IllegalArgumentException("Colour components must be between 0 and 255"); + return value; + } + private HydrogenDisplayType getHydrogenDisplay(Map params) { if (!getBoolean(Param.SUPRESSH, params)) { return HydrogenDisplayType.Provided; @@ -304,18 +411,17 @@ public boolean visible(IAtom iAtom, List list, }); } - boolean doAromaticity = false; - if (getBoolean(Param.DONUTS, extra)) { - doAromaticity = getString(Param.SMARTSQUERY, extra).isEmpty(); - myGenerator = myGenerator.withAromaticDisplay(); - } - final boolean isRxn = !smi.contains("V2000") && !smi.contains("V3000") && isRxnSmi(smi); + final boolean isRgp = smi.contains("RG:"); IReactionSet rxns = null; IAtomContainer mol = null; List mols = null; - Set highlight = new HashSet<>(); + Map> rxnChangeCenters = new HashMap<>(); + Set highlight; + Set smartsHighlight; + List atomHighlightGroups = Collections.emptyList(); + boolean hasAtomHighlightGroups = false; StructureDiagramGenerator sdg = new StructureDiagramGenerator(); sdg.setAlignMappedReaction(getBoolean(Param.ALIGNRXNMAP, extra)); @@ -337,20 +443,19 @@ public boolean visible(IAtom iAtom, List list, highlight = new HashSet<>(); for (IReaction rxn : rxns.reactions()) { - - if (doAromaticity) { - for (IAtomContainer component : rxn) { - Cycles.markRingAtomsAndBonds(component); - Aromaticity.apply(Aromaticity.Model.Daylight, component); - } - } - + Set abbrProtect = new HashSet<>(); Set hits = findHits(getString(Param.SMARTSQUERY, extra), rxn, null, getInt(Param.SMARTSHITLIM, extra)); highlight.addAll(hits); - abbreviate(rxn, abbr, highlight); + abbrProtect.addAll(hits); + if ("rxnchg".equals(annotate)) { + Set centers = findReactionCenterAtoms(rxn); + rxnChangeCenters.put(rxn, centers); + abbrProtect.addAll(centers); + } + abbreviate(rxn, abbr, abbrProtect); for (IAtomContainer component : rxn.getReactants().atomContainers()) { setHydrogenDisplay(component, hDisplayType); MolOp.perceiveRadicals(component); @@ -369,19 +474,24 @@ public boolean visible(IAtom iAtom, List list, if (!GeometryUtil.has2DCoordinates(rxn)) sdg.generateCoordinates(rxn); } + smartsHighlight = new HashSet<>(highlight); } else { mol = loadMol(smi); - - if (doAromaticity) { - Cycles.markRingAtomsAndBonds(mol); - Aromaticity.apply(Aromaticity.Model.Daylight, mol); - } - setHydrogenDisplay(mol, hDisplayType); highlight = findHits(getString(Param.SMARTSQUERY, extra), null, mol, getInt(Param.SMARTSHITLIM, extra)); + smartsHighlight = new HashSet<>(highlight); + String atomLists = getString(Param.ATOMSUBLISTS, extra); + if (!atomLists.isEmpty()) { + atomHighlightGroups = buildAtomHighlightGroups(mol, atomLists); + if (!atomHighlightGroups.isEmpty()) { + hasAtomHighlightGroups = true; + for (HighlightGroup group : atomHighlightGroups) + highlight.addAll(group.members); + } + } abbreviate(mol, abbr, annotate, highlight); MolOp.perceiveRadicals(mol); MolOp.perceiveDativeBonds(mol, doDative); @@ -395,10 +505,6 @@ public boolean visible(IAtom iAtom, List list, myGenerator = myGenerator.withAtomNumbers(); abbr = "false"; break; - case "bondnumber": - myGenerator = myGenerator.withBondNumbers(); - abbr = "false"; - break; case "mapidx": myGenerator = myGenerator.withAtomMapNumbers(); break; @@ -407,23 +513,44 @@ public boolean visible(IAtom iAtom, List list, break; case "colmap": if (isRxn) { - myGenerator = myGenerator.withAtomMapHighlight(new Color[]{new Color(179, 204, 255), - new Color(194, 255, 190), - new Color(255, 166, 166), - new Color(249, 163, 251), - new Color(255, 225, 154), - new Color(227, 227, 227)}) + Color[] colmapColors = colmapColorsForStyle(style); + myGenerator = myGenerator.withAtomMapHighlight(colmapColors) .withOuterGlowHighlight(6d) .withParam(StandardGenerator.Highlighting.class, StandardGenerator.HighlightStyle.OuterGlowFillRings); } else { + Color[] colmapColors = colmapColorsForStyle(style); myGenerator = myGenerator.withOuterGlowHighlight(); myGenerator = myGenerator.withParam(StandardGenerator.Visibility.class, SymbolVisibility.iupacRecommendationsWithoutTerminalCarbon()); for (IAtom atom : mol.atoms()) { Integer mapidx = atom.getProperty(CDKConstants.ATOM_ATOM_MAPPING); - if (mapidx != null && mapidx < COLORS.length) - atom.setProperty(StandardGenerator.HIGHLIGHT_COLOR, COLORS[mapidx]); + if (mapidx != null && mapidx >= 0) + atom.setProperty(StandardGenerator.HIGHLIGHT_COLOR, + colmapColors[mapidx % colmapColors.length]); + } + } + break; + case "rxnchg": + if (isRxn) { + Color rxnChgColor = reactionChangeColorForStyle(style); + myGenerator = myGenerator.withOuterGlowHighlight(6d) + .withParam(StandardGenerator.Highlighting.class, + StandardGenerator.HighlightStyle.OuterGlow); + for (IReaction rxn : rxns.reactions()) { + Set centers = rxnChangeCenters.get(rxn); + if (centers == null) + centers = findReactionCenterAtoms(rxn); + for (IAtom atom : centers) { + atom.setProperty(StandardGenerator.HIGHLIGHT_COLOR, rxnChgColor); + } + for (IAtomContainer part : ReactionManipulator.getAllAtomContainers(rxn)) { + for (IBond bond : part.bonds()) { + if (centers.contains(bond.getBegin()) && centers.contains(bond.getEnd())) { + bond.setProperty(StandardGenerator.HIGHLIGHT_COLOR, rxnChgColor); + } + } + } } } break; @@ -440,22 +567,39 @@ public boolean visible(IAtom iAtom, List list, break; } - // add highlight from atom/bonds hit by the provided SMARTS or - // the MDL HILITE or CXSMILES ha/hb: fields - Color hgCol = new Color(0xaaffaa); - switch (style) { - case "nob": - hgCol = new Color(0xffaaaa); - break; - case "bow": - case "wob": - case "bot": - hgCol = Color.RED; - break; + // add highlight from atom/bonds hit by the provided SMARTS + if (!smartsHighlight.isEmpty()) { + switch (style) { + case "nob": + myGenerator = myGenerator.withHighlight(smartsHighlight, + new Color(0xffaaaa)); + break; + case "bow": + case "wob": + case "bot": + myGenerator = myGenerator.withHighlight(smartsHighlight, + new Color(0xff0000)); + break; + default: + myGenerator = myGenerator.withHighlight(smartsHighlight, + new Color(0xaaffaa)); + break; + } } - myGenerator = myGenerator.withParam(RendererModel.SelectionColor.class, hgCol) - .withHighlight(highlight, hgCol); + if (hasAtomHighlightGroups && !"colmap".equals(annotate)) { + myGenerator = myGenerator.withParam(StandardGenerator.Highlighting.class, + StandardGenerator.HighlightStyle.OuterGlow); + } + + Color[] highlightPalette = highlightColorsForStyle(style); + for (int i = 0; i < atomHighlightGroups.size(); i++) { + HighlightGroup group = atomHighlightGroups.get(i); + if (!group.members.isEmpty()) { + Color color = group.color != null ? group.color : highlightPalette[i % highlightPalette.length]; + myGenerator = myGenerator.withHighlight(group.members, color); + } + } if (getBoolean(Param.SHOWTITLE, extra)) { if (isRxn) @@ -482,14 +626,12 @@ public boolean visible(IAtom iAtom, List list, } } - // and reaction? - MolOp.setMulticenterStyle(mol, - getParam(Param.MULTICENTER, extra, this::parseMulticenter)); - final String fmtlc = fmt.toLowerCase(Locale.ROOT); // pre-render the depiction - final Depiction depiction = isRxn ? myGenerator.depict(rxns) : myGenerator.depict(mol); + final Depiction depiction = isRxn ? myGenerator.depict(rxns) + : isRgp ? myGenerator.depict(mols, mols.size(), 1) + : myGenerator.depict(mol); switch (fmtlc) { case Depiction.SVG_FMT: @@ -508,6 +650,368 @@ public boolean visible(IAtom iAtom, List list, throw new IllegalArgumentException("Unsupported format."); } + /** + * Restful reaction transform endpoint. + * + * @param smi input molecule SMILES + * @param smirks reaction transform (SMIRKS) + * @param fmt output format + * @param style preset style COW (Color-on-white), COB, BOW, COW + * @return the depicted transformed reactions + * @throws CDKException something not okay with input + * @throws IOException problem reading/writing request + */ + @RequestMapping("react/{style}/{fmt}") + public HttpEntity react(@RequestParam("smi") String smi, + @RequestParam("smirks") String smirks, + @PathVariable("fmt") String fmt, + @PathVariable("style") String style, + @RequestParam Map extra) throws + CDKException, + IOException { + + String abbr = getString(Param.ABBREVIATE, extra); + String annotate = getString(Param.ANNOTATE, extra); + + HydrogenDisplayType hDisplayType = getHydrogenDisplay(extra); + + // Note: DepictionGenerator is immutable + DepictionGenerator myGenerator = generator.withSize(getDouble(Param.WIDTH, extra), + getDouble(Param.HEIGHT, extra)) + .withZoom(getDouble(Param.ZOOM, extra)); + + // Configure style preset + myGenerator = withStyle(myGenerator, style); + myGenerator = withBgFgColors(extra, myGenerator); + myGenerator = myGenerator.withAnnotationScale(0.7) + .withAnnotationColor(Color.RED); + myGenerator = myGenerator.withParam(StandardGenerator.StrokeRatio.class, getDouble(Param.RATIO, extra)); + + // align rxn maps + myGenerator = myGenerator.withMappedRxnAlign(getBoolean(Param.ALIGNRXNMAP, extra)); + + // Improved depiction of anatomised graphs, e.g. ***1*****1** + if (getBoolean(Param.ANON, extra)) { + myGenerator = myGenerator.withParam(Visibility.class, + new SymbolVisibility() { + @Override + public boolean visible(IAtom iAtom, List list, + RendererModel rendererModel) { + return list.isEmpty(); + } + }); + } + + IAtomContainer template = loadMol(smi); + List productSmiles = transformProductSmiles(template, + smirks, + getBoolean(Param.REVERSE, extra)); + if (productSmiles.isEmpty()) { + return makeResponse("No products generated".getBytes(StandardCharsets.UTF_8), "text/plain"); + } + + IReactionSet rxns = builder.newInstance(IReactionSet.class); + for (String productSmi : productSmiles) { + IReaction rxn = builder.newReaction(); + rxn.addReactant(cloneMol(template)); + rxn.addProduct(loadMol(productSmi)); + rxn.setDirection(getParam(Param.ARROW, extra, this::parseArrowParam)); + rxn.setProperty(CDKConstants.TITLE, productSmi); + rxns.addReaction(rxn); + } + + Set highlight = new HashSet<>(); + Map> rxnChangeCenters = new HashMap<>(); + + StructureDiagramGenerator sdg = new StructureDiagramGenerator(); + sdg.setAlignMappedReaction(getBoolean(Param.ALIGNRXNMAP, extra)); + MolOp.DativeBond doDative = getParam(Param.DATIVE, extra, this::parseDativeParam); + + for (IReaction rxn : rxns.reactions()) { + Set abbrProtect = new HashSet<>(); + Set hits = findHits(getString(Param.SMARTSQUERY, extra), + rxn, + null, + getInt(Param.SMARTSHITLIM, extra)); + highlight.addAll(hits); + abbrProtect.addAll(hits); + if ("rxnchg".equals(annotate)) { + Set centers = findReactionCenterAtoms(rxn); + rxnChangeCenters.put(rxn, centers); + abbrProtect.addAll(centers); + } + abbreviate(rxn, abbr, abbrProtect); + for (IAtomContainer component : rxn.getReactants().atomContainers()) { + setHydrogenDisplay(component, hDisplayType); + MolOp.perceiveRadicals(component); + MolOp.perceiveDativeBonds(component, doDative); + } + for (IAtomContainer component : rxn.getProducts().atomContainers()) { + setHydrogenDisplay(component, hDisplayType); + MolOp.perceiveRadicals(component); + MolOp.perceiveDativeBonds(component, doDative); + } + for (IAtomContainer component : rxn.getAgents().atomContainers()) { + setHydrogenDisplay(component, hDisplayType); + MolOp.perceiveRadicals(component); + MolOp.perceiveDativeBonds(component, doDative); + } + if (!GeometryUtil.has2DCoordinates(rxn)) + sdg.generateCoordinates(rxn); + } + + // Add annotations + switch (annotate) { + case "number": + myGenerator = myGenerator.withAtomNumbers(); + abbr = "false"; + break; + case "mapidx": + myGenerator = myGenerator.withAtomMapNumbers(); + break; + case "atomvalue": + myGenerator = myGenerator.withAtomValues(); + break; + case "colmap": + myGenerator = myGenerator.withAtomMapHighlight(colmapColorsForStyle(style)) + .withOuterGlowHighlight(6d) + .withParam(StandardGenerator.Highlighting.class, + StandardGenerator.HighlightStyle.OuterGlowFillRings); + break; + case "rxnchg": + Color rxnChgColor = reactionChangeColorForStyle(style); + myGenerator = myGenerator.withOuterGlowHighlight(6d) + .withParam(StandardGenerator.Highlighting.class, + StandardGenerator.HighlightStyle.OuterGlow); + for (IReaction rxn : rxns.reactions()) { + Set centers = rxnChangeCenters.get(rxn); + if (centers == null) + centers = findReactionCenterAtoms(rxn); + for (IAtom atom : centers) { + atom.setProperty(StandardGenerator.HIGHLIGHT_COLOR, rxnChgColor); + } + for (IAtomContainer part : ReactionManipulator.getAllAtomContainers(rxn)) { + for (IBond bond : part.bonds()) { + if (centers.contains(bond.getBegin()) && centers.contains(bond.getEnd())) { + bond.setProperty(StandardGenerator.HIGHLIGHT_COLOR, rxnChgColor); + } + } + } + } + break; + case "cip": + for (IReaction rxn : rxns.reactions()) { + for (IAtomContainer part : ReactionManipulator.getAllAtomContainers(rxn)) { + annotateCip(part); + } + } + break; + } + + // add highlight from atom/bonds hit by the provided SMARTS + switch (style) { + case "nob": + myGenerator = myGenerator.withHighlight(highlight, + new Color(0xffaaaa)); + break; + case "bow": + case "wob": + case "bot": + myGenerator = myGenerator.withHighlight(highlight, + new Color(0xff0000)); + break; + default: + myGenerator = myGenerator.withHighlight(highlight, + new Color(0xaaffaa)); + break; + } + + if (getBoolean(Param.SHOWTITLE, extra)) { + myGenerator = myGenerator.withRxnTitle(); + } + + // reactions are laid out in the main depiction gen + if (getBoolean(Param.FLIP, extra)) { + for (IAtomContainer part : ReactionSetManipulator.getAllAtomContainers(rxns)) + flip(part); + } + int rotate = getInt(Param.ROTATE, extra); + if (rotate != 0) { + for (IAtomContainer part : ReactionSetManipulator.getAllAtomContainers(rxns)) + rotate(part, rotate); + } + + final String fmtlc = fmt.toLowerCase(Locale.ROOT); + final Depiction depiction = myGenerator.depict(rxns); + + switch (fmtlc) { + case Depiction.SVG_FMT: + return makeResponse(depiction.toSvgStr(getString(Param.SVGUNITS, extra)) + .getBytes(), "image/svg+xml"); + case Depiction.PDF_FMT: + return makeResponse(depiction.toPdfStr().getBytes(), "application/pdf"); + case Depiction.PNG_FMT: + case Depiction.JPG_FMT: + case Depiction.GIF_FMT: + ByteArrayOutputStream bao = new ByteArrayOutputStream(); + ImageIO.write(depiction.toImg(), fmtlc, bao); + return makeResponse(bao.toByteArray(), "image/" + fmtlc); + } + + throw new IllegalArgumentException("Unsupported format."); + } + + @RequestMapping("map/{style}/{fmt}") + public HttpEntity map(@RequestParam("smi") String smi, + @PathVariable("fmt") String fmt, + @PathVariable("style") String style, + @RequestParam Map extra) throws + CDKException, + IOException { + return depict(mapReactionSmiles(smi), fmt, style, extra); + } + + @RequestMapping("map/smi") + public HttpEntity mapSmiles(@RequestParam("smi") String smi) { + return makeResponse(mapReactionSmiles(smi).getBytes(StandardCharsets.UTF_8), "text/plain"); + } + + String mapReactionSmiles(String smi) { + String input = normalizeReactionInput(smi); + if (hasReactionAgents(input)) + return mapReactionSmilesWithAgents(input); + + return mapReactionSmilesDirect(input); + } + + private String mapReactionSmilesDirect(String smi) { + ReactionResult result; + try { + result = RDT.map(smi, true, true); + } catch (RuntimeException ex) { + String message = ex.getMessage(); + if (message == null || message.isEmpty()) + message = "Could not map reaction SMILES"; + throw new InvalidReactionMappingException(message, ex); + } + + if (result == null || !result.isMapped() || result.getMappedSmiles() == null || result.getMappedSmiles().isEmpty()) + throw new InvalidReactionMappingException("Could not map reaction SMILES"); + + return result.getMappedSmiles(); + } + + private String mapReactionSmilesWithAgents(String smi) { + String[] parts = smi.split(">", -1); + if (parts.length != 3) + throw new InvalidReactionMappingException("Expected reaction SMILES in 'reactants>agents>products' format, e.g. 'CCO>>CC=O' or 'CCO>O>CC=O'"); + + String mapped = mapReactionSmilesDirect(parts[0] + ">>" + parts[2]); + String[] mappedParts = mapped.split(">>", -1); + if (mappedParts.length != 2) + throw new InvalidReactionMappingException("Could not map reaction SMILES"); + + return mappedParts[0] + ">" + parts[1] + ">" + mappedParts[1]; + } + + private String normalizeReactionInput(String smi) { + if (smi == null) + throw new InvalidReactionMappingException("Reaction SMILES must not be null"); + + String trimmed = smi.trim(); + if (trimmed.isEmpty()) + throw new InvalidReactionMappingException("Reaction SMILES must not be empty"); + + int lastPipe = trimmed.lastIndexOf('|'); + if (lastPipe >= 0) { + int firstPipe = trimmed.indexOf('|'); + if (firstPipe >= 0 && firstPipe < lastPipe) + return trimmed.substring(0, lastPipe + 1).trim(); + } + + int space = trimmed.indexOf(' '); + int tab = trimmed.indexOf('\t'); + int split = -1; + if (space >= 0 && tab >= 0) + split = Math.min(space, tab); + else if (space >= 0) + split = space; + else if (tab >= 0) + split = tab; + + String input = split > 0 ? trimmed.substring(0, split) : trimmed; + if (!isReactionSmiles(input)) { + throw new InvalidReactionMappingException("Expected reaction SMILES in 'reactants>agents>products' format, e.g. 'CCO>>CC=O' or 'CCO>O>CC=O'"); + } + return input; + } + + private boolean isReactionSmiles(String smi) { + int first = smi.indexOf('>'); + int last = smi.lastIndexOf('>'); + if (first <= 0 || last <= first || last >= smi.length() - 1) + return false; + if (smi.indexOf('>', first + 1) != last) + return false; + return true; + } + + private boolean hasReactionAgents(String smi) { + int first = smi.indexOf('>'); + int last = smi.lastIndexOf('>'); + return first >= 0 && last > first && !smi.substring(first + 1, last).isEmpty(); + } + + List transformProductSmiles(String smi, String smirks, boolean reverse) throws CDKException { + return transformProductSmiles(loadMol(smi), smirks, reverse); + } + + private List transformProductSmiles(IAtomContainer input, String smirks, boolean reverse) throws CDKException { + String smirksToApply = maybeReverseSmirks(smirks, reverse); + SmirksTransform transform = compileSmirks(smirksToApply); + SmilesGenerator smigen = SmilesGenerator.unique(); + Set products = new LinkedHashSet<>(); + int k = 1; + for (IAtom atom : input.atoms()) { + atom.setProperty(CDKConstants.ATOM_ATOM_MAPPING, k++); + } + for (IAtomContainer product : transform.apply(input, Transform.Mode.Unique, MAX_REACTION_PRODUCTS)) { + if (products.size() >= MAX_REACTION_PRODUCTS) + break; + products.add(smigen.create(product)); + } + return new ArrayList<>(products); + } + + private SmirksTransform compileSmirks(String smirks) { + try { + return Smirks.compile(smirks); + } catch (RuntimeException ex) { + String message = ex.getMessage(); + if (message == null || message.isEmpty()) + message = "Could not parse SMIRKS"; + throw new InvalidSmirksException(message, ex); + } + } + + private String maybeReverseSmirks(String smirks, boolean reverse) { + if (!reverse) + return smirks; + String[] parts = smirks.split(">", -1); + if (parts.length != 3) { + throw new InvalidSmirksException("SMIRKS must be in 'reactants>agents>products' format"); + } + return parts[2] + ">" + parts[1] + ">" + parts[0]; + } + + private IAtomContainer cloneMol(IAtomContainer mol) { + try { + return (IAtomContainer) mol.clone(); + } catch (CloneNotSupportedException e) { + throw new IllegalStateException("Could not clone input molecule", e); + } + } + private MolOp.DativeBond parseDativeParam(String s) { if (s == null || s.isEmpty()) return null; @@ -523,27 +1027,6 @@ private MolOp.DativeBond parseDativeParam(String s) { } } - private MolOp.MulticenterStyle parseMulticenter(String s) { - if (s == null || s.isEmpty()) - return null; - switch (s.toLowerCase(Locale.ROOT)) { - case "p": - return MolOp.MulticenterStyle.Provided; - case "d": - return MolOp.MulticenterStyle.Dative; - case "a": - return MolOp.MulticenterStyle.Dashed; - case "an": - return MolOp.MulticenterStyle.DashedNeutral; - case "h": - return MolOp.MulticenterStyle.Hidden; - case "hn": - return MolOp.MulticenterStyle.HiddenNeutral; - default: - return null; - } - } - private IReaction.Direction parseArrowParam(String s) { if (s == null || s.isEmpty()) return null; @@ -725,24 +1208,224 @@ else if (atom.getProperty(BaseMol.CIP_LABEL_KEY) != null) private void setHydrogenDisplay(IAtomContainer mol, HydrogenDisplayType hDisplayType) { switch (hDisplayType) { case Minimal: - AtomContainerManipulator.normalizeHydrogens(mol, HydrogenState.Minimal); + AtomContainerManipulator.suppressHydrogens(mol); break; case Explicit: - AtomContainerManipulator.normalizeHydrogens(mol, HydrogenState.Explicit); - break; - case Stereo: - AtomContainerManipulator.normalizeHydrogens(mol, HydrogenState.Stereo); - break; - case Smart: - AtomContainerManipulator.normalizeHydrogens(mol, HydrogenState.Depiction); + AtomContainerManipulator.convertImplicitToExplicitHydrogens(mol); break; + case Stereo: { + AtomContainerManipulator.suppressHydrogens(mol); + List ses = new ArrayList<>(); + for (IStereoElement se : mol.stereoElements()) { + switch (se.getConfigClass()) { + case IStereoElement.Tetrahedral: { + IAtom focus = (IAtom) se.getFocus(); + if (focus.getImplicitHydrogenCount() == 1) { + focus.setImplicitHydrogenCount(0); + IAtom hydrogen = sproutHydrogen(mol, focus); + IStereoElement tmp = se.map(Collections.singletonMap(focus, hydrogen)); + // need to keep focus same + TetrahedralChirality e = new TetrahedralChirality(focus, + (IAtom[]) tmp.getCarriers().toArray(new IAtom[4]), + tmp.getConfig()); + e.setGroupInfo(se.getGroupInfo()); + ses.add(e); + } else { + ses.add(se); + } + } + break; + case IStereoElement.CisTrans: { + IBond focus = (IBond) se.getFocus(); + IAtom beg = focus.getBegin(); + IAtom end = focus.getEnd(); + if (beg.getImplicitHydrogenCount() == 1) { + beg.setImplicitHydrogenCount(0); + sproutHydrogen(mol, beg); + } + if (end.getImplicitHydrogenCount() == 1) { + end.setImplicitHydrogenCount(0); + sproutHydrogen(mol, end); + } + // don't need to update stereo element + ses.add(se); + } + break; + default: + ses.add(se); + break; + } + } + mol.setStereoElements(ses); + } + break; + case Smart: { + AtomContainerManipulator.suppressHydrogens(mol); + Cycles.markRingAtomsAndBonds(mol); + List ses = new ArrayList<>(); + for (IStereoElement se : mol.stereoElements()) { + switch (se.getConfigClass()) { + case IStereoElement.Tetrahedral: { + IAtom focus = (IAtom) se.getFocus(); + if (focus.getImplicitHydrogenCount() == 1 && + shouldAddH(mol, focus, mol.getConnectedBondsList(focus))) { + // need to keep focus same + TetrahedralChirality e = new TetrahedralChirality(focus, + getExplHCarriers(mol, se, focus), + se.getConfig()); + e.setGroupInfo(se.getGroupInfo()); + ses.add(e); + } else { + ses.add(se); + } + } + break; + case IStereoElement.SquarePlanar: + { + IAtom focus = (IAtom) se.getFocus(); + if (focus.getImplicitHydrogenCount() > 0) { + ses.add(new SquarePlanar(focus, getExplHCarriers(mol, se, focus), se.getConfig())); + } else { + ses.add(se); + } + } + break; + case IStereoElement.TrigonalBipyramidal: + { + IAtom focus = (IAtom) se.getFocus(); + if (focus.getImplicitHydrogenCount() > 0) { + ses.add(new TrigonalBipyramidal(focus, getExplHCarriers(mol, se, focus), se.getConfig())); + } else { + ses.add(se); + } + } + break; + case IStereoElement.Octahedral: + { + IAtom focus = (IAtom) se.getFocus(); + if (focus.getImplicitHydrogenCount() > 0) { + ses.add(new Octahedral(focus, getExplHCarriers(mol, se, focus), se.getConfig())); + } else { + ses.add(se); + } + } + break; + case IStereoElement.CisTrans: { + IBond focus = (IBond) se.getFocus(); + IAtom begin = focus.getBegin(); + IAtom end = focus.getEnd(); + IAtom hydrogenBegin = null; + IAtom hydrogenEnd = null; + + if (begin.getImplicitHydrogenCount() == 1 && + shouldAddH(mol, begin, mol.getConnectedBondsList(begin))) { + begin.setImplicitHydrogenCount(0); + hydrogenBegin = sproutHydrogen(mol, begin); + } + + if (end.getImplicitHydrogenCount() == 1 && + shouldAddH(mol, end, mol.getConnectedBondsList(end))) { + end.setImplicitHydrogenCount(0); + hydrogenEnd = sproutHydrogen(mol, end); + } + + if (hydrogenBegin != null || hydrogenEnd != null) { + Map map = new HashMap<>(); + map.put(begin, hydrogenBegin); + map.put(end, hydrogenEnd); + ses.add(se.map(map)); + } else { + ses.add(se); + } + } + break; + case IStereoElement.Allenal: { + IAtom focus = (IAtom) se.getFocus(); + IAtom[] terminals = ExtendedTetrahedral.findTerminalAtoms(mol, focus); + IAtom hydrogen1 = null; + IAtom hydrogen2 = null; + if (terminals[0].getImplicitHydrogenCount() == 1) { + terminals[0].setImplicitHydrogenCount(0); + hydrogen1 = sproutHydrogen(mol, terminals[0]); + } + if (terminals[1].getImplicitHydrogenCount() == 1) { + terminals[1].setImplicitHydrogenCount(0); + hydrogen2 = sproutHydrogen(mol, terminals[1]); + } + if (hydrogen1 != null || hydrogen2 != null) { + Map map = new HashMap<>(); + if (hydrogen1 != null) + map.put(terminals[0], hydrogen1); + if (hydrogen2 != null) + map.put(terminals[1], hydrogen2); + // find as focus is not one of the terminals + IStereoElement tmp = se.map(map); + ses.add(tmp); + } else { + ses.add(se); + } + } + break; + default: + ses.add(se); + break; + } + } + mol.setStereoElements(ses); + } + break; case Provided: default: - // do nothing break; } } + // utility to sprout multiple hydrogens for stereo centres + private IAtom[] getExplHCarriers(IAtomContainer mol, IStereoElement se, IAtom focus) { + Deque hydrogens = new ArrayDeque<>(); + for (int i = 0; i < focus.getImplicitHydrogenCount(); i++) { + hydrogens.add(sproutHydrogen(mol, focus)); + } + focus.setImplicitHydrogenCount(0); + List carriers = new ArrayList<>(); + for (IAtom carrier : (List) se.getCarriers()) { + carriers.add(carrier.equals(focus) && !hydrogens.isEmpty() ? hydrogens.poll() : carrier); + } + return carriers.toArray(new IAtom[0]); + } + + private boolean shouldAddH(IAtomContainer mol, IAtom atom, Iterable bonds) { + int count = 0; + for (IBond bond : bonds) { + IAtom nbr = bond.getOther(atom); + if (bond.isInRing()) { + ++count; + } else { + for (IStereoElement se : mol.stereoElements()) { + if (se.getConfigClass() == IStereoElement.TH && + se.getFocus().equals(nbr)) { + count++; + } + } + } + // hydrogen isotope + if (nbr.getAtomicNumber() == 1 && + nbr.getMassNumber() != null) + return true; + } + return count == 3; + } + + private IAtom sproutHydrogen(IAtomContainer mol, IAtom focus) { + IAtom hydrogen = mol.getBuilder().newAtom(); + hydrogen.setAtomicNumber(1); + hydrogen.setSymbol("H"); + hydrogen.setImplicitHydrogenCount(0); + mol.addAtom(hydrogen); + mol.addBond(mol.indexOf(focus), mol.getAtomCount() - 1, IBond.Order.SINGLE); + return mol.getAtom(mol.getAtomCount() - 1); + } + private void contractHydrates(IAtomContainer mol) { Set hydrate = new HashSet<>(); for (IAtom atom : mol.atoms()) { @@ -802,6 +1485,7 @@ private void abbreviate(IReaction rxn, atomSet.put((IAtom) obj, 1); } + Multimap sgroupmap = ArrayListMultimap.create(); switch (mode.toLowerCase()) { case "true": case "on": @@ -836,6 +1520,38 @@ private void abbreviate(IReaction rxn, } break; } + + Set include = new HashSet<>(); + for (Map.Entry e : sgroupmap.entries()) { + final IAtomContainer mol = e.getKey(); + final Sgroup abbrv = e.getValue(); + int numAtoms = mol.getAtomCount(); + if (abbrv.getBonds().isEmpty()) { + include.add(abbrv.getSubscript()); + } else { + int numAbbr = abbrv.getAtoms().size(); + double f = numAbbr / (double) numAtoms; + if (numAtoms - numAbbr > 1 && f <= 0.4) { + include.add(abbrv.getSubscript()); + } + } + } + + for (Map.Entry> e : sgroupmap.asMap().entrySet()) { + final IAtomContainer mol = e.getKey(); + + List sgroups = mol.getProperty(CDKConstants.CTAB_SGROUPS); + if (sgroups == null) + sgroups = new ArrayList<>(); + else + sgroups = new ArrayList<>(sgroups); + mol.setProperty(CDKConstants.CTAB_SGROUPS, sgroups); + + for (Sgroup abbrv : e.getValue()) { + if (include.contains(abbrv.getSubscript())) + sgroups.add(abbrv); + } + } } private void abbreviate(IAtomContainer mol, @@ -953,6 +1669,16 @@ private static DepictionGenerator withStyle(DepictionGenerator generator, .withBackgroundColor(Color.BLACK) .withOuterGlowHighlight(); break; + case "not": + generator = generator.withAtomColors(new NobColorer()) + .withBackgroundColor(new Color(0, 0, 0, 0)) + .withOuterGlowHighlight(); + break; + case "wcot": + generator = generator.withAtomColors(new CobColorer()) + .withBackgroundColor(new Color(0, 0, 0, 0)) + .withOuterGlowHighlight(); + break; } return generator; } @@ -1008,6 +1734,76 @@ public Color getAtomColor(IAtom atom, Color color) { } } + private List buildAtomHighlightGroups(IAtomContainer mol, String specification) { + if (specification == null || specification.trim().isEmpty()) + return Collections.emptyList(); + + List groups = new ArrayList<>(); + String[] sublists = specification.split("[;|]"); + for (String sublist : sublists) { + String trimmed = sublist.trim(); + if (trimmed.isEmpty()) + continue; + + Color override = null; + String indices = trimmed; + int colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) { + String colorSpec = trimmed.substring(0, colonIdx).trim(); + if (!colorSpec.isEmpty()) { + try { + override = getColor(colorSpec); + } catch (IllegalArgumentException ex) { + override = null; + } + } + indices = trimmed.substring(colonIdx + 1).trim(); + } + + if (indices.isEmpty()) + continue; + + Set members = new LinkedHashSet<>(); + List atoms = new ArrayList<>(); + for (String token : indices.split(",")) { + String t = token.trim(); + if (t.isEmpty()) + continue; + try { + int idx = Integer.parseInt(t); + if (idx < 0 || idx >= mol.getAtomCount()) + continue; + IAtom atom = mol.getAtom(idx); + if (members.add(atom)) { + for (IAtom other : atoms) { + IBond bond = mol.getBond(atom, other); + if (bond != null) + members.add(bond); + } + atoms.add(atom); + } + } catch (NumberFormatException ex) { + // skip invalid index entries + } + } + + if (!members.isEmpty()) + groups.add(new HighlightGroup(members, override)); + } + + return groups; + } + + private static final class HighlightGroup { + final Set members; + final Color color; + + HighlightGroup(Set members, Color color) { + this.members = members; + this.color = color; + } + } + /** * Find matching atoms and bonds in the reaction or molecule. * @@ -1052,9 +1848,410 @@ private Set findHits(final String sma, return highlight; } - @ExceptionHandler({Exception.class, InvalidSmilesException.class}) + static Set findReactionCenterAtoms(IReaction rxn) { + Set centers = new LinkedHashSet<>(); + + List reactants = new ArrayList<>(); + List products = new ArrayList<>(); + + Map reactantOwner = new HashMap<>(); + Map productOwner = new HashMap<>(); + + Map> reactantMapped = new LinkedHashMap<>(); + Map> productMapped = new LinkedHashMap<>(); + + Set reactantUnmapped = new LinkedHashSet<>(); + Set productUnmapped = new LinkedHashSet<>(); + + for (IAtomContainer reactant : rxn.getReactants().atomContainers()) { + reactants.add(reactant); + indexReactionAtoms(reactant, reactantOwner, reactantMapped, reactantUnmapped); + } + for (IAtomContainer product : rxn.getProducts().atomContainers()) { + products.add(product); + indexReactionAtoms(product, productOwner, productMapped, productUnmapped); + } + + Set allMapIdx = new LinkedHashSet<>(); + allMapIdx.addAll(reactantMapped.keySet()); + allMapIdx.addAll(productMapped.keySet()); + for (Integer mapIdx : allMapIdx) { + List left = reactantMapped.get(mapIdx); + List right = productMapped.get(mapIdx); + if (left == null || right == null || left.size() != 1 || right.size() != 1) { + if (left != null) + centers.addAll(left); + if (right != null) + centers.addAll(right); + continue; + } + + IAtom leftAtom = left.get(0); + IAtom rightAtom = right.get(0); + String leftSig = mappedAtomSignature(leftAtom, reactantOwner.get(leftAtom)); + String rightSig = mappedAtomSignature(rightAtom, productOwner.get(rightAtom)); + if (!leftSig.equals(rightSig)) { + centers.add(leftAtom); + centers.add(rightAtom); + } + } + + if (!reactantUnmapped.isEmpty() || !productUnmapped.isEmpty()) { + Map> reactantByContainer = partitionByContainer(reactantUnmapped, + reactantOwner); + Map> productByContainer = partitionByContainer(productUnmapped, + productOwner); + + Map> reactantHashesByContainer = new LinkedHashMap<>(); + Map> productHashesByContainer = new LinkedHashMap<>(); + Map> reactantComponentKey = new LinkedHashMap<>(); + Map> productComponentKey = new LinkedHashMap<>(); + + for (Map.Entry> e : reactantByContainer.entrySet()) { + Map hashes = wlHashesForAtoms(Collections.singleton(e.getKey()), + e.getValue(), + WL_ROUNDS); + reactantHashesByContainer.put(e.getKey(), hashes); + addContainerByKey(reactantComponentKey, hashMultisetKey(e.getValue(), hashes), e.getKey()); + } + for (Map.Entry> e : productByContainer.entrySet()) { + Map hashes = wlHashesForAtoms(Collections.singleton(e.getKey()), + e.getValue(), + WL_ROUNDS); + productHashesByContainer.put(e.getKey(), hashes); + addContainerByKey(productComponentKey, hashMultisetKey(e.getValue(), hashes), e.getKey()); + } + + Set reactantResidual = new LinkedHashSet<>(); + Set productResidual = new LinkedHashSet<>(); + Set allComponentKeys = new LinkedHashSet<>(); + allComponentKeys.addAll(reactantComponentKey.keySet()); + allComponentKeys.addAll(productComponentKey.keySet()); + for (String key : allComponentKeys) { + Deque left = reactantComponentKey.get(key); + Deque right = productComponentKey.get(key); + while (left != null && right != null && !left.isEmpty() && !right.isEmpty()) { + IAtomContainer leftContainer = left.removeFirst(); + IAtomContainer rightContainer = right.removeFirst(); + markUnmatchedByHash(reactantByContainer.get(leftContainer), + reactantHashesByContainer.get(leftContainer), + productByContainer.get(rightContainer), + productHashesByContainer.get(rightContainer), + centers); + } + if (left != null) { + while (!left.isEmpty()) { + reactantResidual.addAll(reactantByContainer.get(left.removeFirst())); + } + } + if (right != null) { + while (!right.isEmpty()) { + productResidual.addAll(productByContainer.get(right.removeFirst())); + } + } + } + + if (!reactantResidual.isEmpty() || !productResidual.isEmpty()) { + Map reactantResidualHashes = wlHashesForAtoms(reactants, + reactantResidual, + WL_ROUNDS); + Map productResidualHashes = wlHashesForAtoms(products, + productResidual, + WL_ROUNDS); + markUnmatchedByHash(reactantResidual, + reactantResidualHashes, + productResidual, + productResidualHashes, + centers); + } + } + + return centers; + } + + static Map wlHashesForAtoms(Collection containers, + Set atoms, + int rounds) { + Map owner = new HashMap<>(); + Set allAtoms = new LinkedHashSet<>(); + for (IAtomContainer container : containers) { + for (IAtom atom : container.atoms()) { + allAtoms.add(atom); + owner.put(atom, container); + } + } + + Map labels = new HashMap<>(); + for (IAtom atom : allAtoms) + labels.put(atom, wlBaseLabel(atom)); + + int nRounds = Math.max(0, rounds); + for (int i = 0; i < nRounds; i++) { + Map next = new HashMap<>(); + for (IAtom atom : allAtoms) { + IAtomContainer container = owner.get(atom); + List neighborTokens = new ArrayList<>(); + if (container != null) { + for (IBond bond : container.getConnectedBondsList(atom)) { + IAtom nbr = bond.getOther(atom); + String nbrLabel = labels.get(nbr); + if (nbrLabel == null) + nbrLabel = wlBaseLabel(nbr); + neighborTokens.add(bondToken(atom, nbr, bond) + ":" + nbrLabel); + } + } + Collections.sort(neighborTokens); + String merged = labels.get(atom) + "|" + String.join(",", neighborTokens); + next.put(atom, Integer.toHexString(merged.hashCode())); + } + labels = next; + } + + Map result = new LinkedHashMap<>(); + for (IAtom atom : atoms) { + if (labels.containsKey(atom)) + result.put(atom, labels.get(atom)); + } + return result; + } + + private static void indexReactionAtoms(IAtomContainer container, + Map owner, + Map> mapped, + Set unmapped) { + for (IAtom atom : container.atoms()) { + owner.put(atom, container); + int mapIdx = atomMapIdx(atom); + if (mapIdx > 0) { + List atoms = mapped.get(mapIdx); + if (atoms == null) { + atoms = new ArrayList<>(); + mapped.put(mapIdx, atoms); + } + atoms.add(atom); + } else { + unmapped.add(atom); + } + } + } + + private static int atomMapIdx(IAtom atom) { + if (atom.getMapIdx() > 0) + return atom.getMapIdx(); + + Object val = atom.getProperty(CDKConstants.ATOM_ATOM_MAPPING); + if (val instanceof Number) + return Math.max(0, ((Number) val).intValue()); + + if (val instanceof String) { + try { + return Math.max(0, Integer.parseInt((String) val)); + } catch (NumberFormatException ignored) { + return 0; + } + } + return 0; + } + + private static String mappedAtomSignature(IAtom atom, IAtomContainer container) { + if (container == null) + return ""; + + List edgeTokens = new ArrayList<>(); + for (IBond bond : container.getConnectedBondsList(atom)) { + IAtom nbr = bond.getOther(atom); + edgeTokens.add(neighborToken(nbr, container) + "|" + bondToken(atom, nbr, bond)); + } + Collections.sort(edgeTokens); + return String.join(",", edgeTokens); + } + + private static String neighborToken(IAtom atom, IAtomContainer container) { + int mapIdx = atomMapIdx(atom); + if (mapIdx > 0) + return "M:" + mapIdx; + + return "U:" + atomicNumber(atom) + + ":" + formalCharge(atom) + + ":" + bool(atom.isAromatic()) + + ":" + degree(atom, container); + } + + private static String bondToken(IBond bond) { + return (bond.getOrder() != null ? bond.getOrder().name() : "UNSET") + + ":" + bool(bond.isAromatic()); + } + + private static String bondToken(IAtom a, IAtom b, IBond bond) { + // Aromatic bonds are resonance-equivalent: ignore alternating SINGLE/DOUBLE placement. + if (bond.isAromatic() || (a.isAromatic() && b.isAromatic())) + return "AROM:1"; + return bondToken(bond); + } + + private static Map> groupByHash(Set atoms, Map hashes) { + Map> grouped = new LinkedHashMap<>(); + for (IAtom atom : atoms) { + String hash = hashes.get(atom); + if (hash == null) + hash = wlBaseLabel(atom); + List bucket = grouped.get(hash); + if (bucket == null) { + bucket = new ArrayList<>(); + grouped.put(hash, bucket); + } + bucket.add(atom); + } + return grouped; + } + + private static void markUnmatchedByHash(Set leftAtoms, + Map leftHashes, + Set rightAtoms, + Map rightHashes, + Set centers) { + Map> leftByHash = groupByHash(leftAtoms, leftHashes); + Map> rightByHash = groupByHash(rightAtoms, rightHashes); + + Set allHashes = new LinkedHashSet<>(); + allHashes.addAll(leftByHash.keySet()); + allHashes.addAll(rightByHash.keySet()); + for (String hash : allHashes) { + List left = leftByHash.get(hash); + List right = rightByHash.get(hash); + int leftSize = left != null ? left.size() : 0; + int rightSize = right != null ? right.size() : 0; + if (leftSize == 0) { + // Avoid marking aromatic atoms that only appear "new" due to hash mismatch (e.g. ring + // atoms in benzene->toluene), which would produce ambiguous scattered highlights. + if (right != null) { + for (IAtom a : right) { + if (!a.isAromatic()) + centers.add(a); + } + } + continue; + } + if (rightSize == 0) { + if (left != null) + centers.addAll(left); + continue; + } + + // Ambiguous surplus in duplicate buckets can produce visually scattered highlights. + // Keep this fallback conservative unless at least one side is unambiguous. + if (leftSize != rightSize && leftSize > 1 && rightSize > 1) + continue; + + int keep = Math.min(leftSize, rightSize); + if (left != null) { + for (int i = keep; i < left.size(); i++) + centers.add(left.get(i)); + } + if (right != null) { + for (int i = keep; i < right.size(); i++) + centers.add(right.get(i)); + } + } + } + + private static Map> partitionByContainer(Set atoms, + Map owner) { + Map> grouped = new LinkedHashMap<>(); + for (IAtom atom : atoms) { + IAtomContainer container = owner.get(atom); + if (container == null) + continue; + Set set = grouped.get(container); + if (set == null) { + set = new LinkedHashSet<>(); + grouped.put(container, set); + } + set.add(atom); + } + return grouped; + } + + private static void addContainerByKey(Map> grouped, + String key, + IAtomContainer container) { + Deque queue = grouped.get(key); + if (queue == null) { + queue = new ArrayDeque<>(); + grouped.put(key, queue); + } + queue.add(container); + } + + private static String hashMultisetKey(Set atoms, Map hashes) { + Map counts = new LinkedHashMap<>(); + for (IAtom atom : atoms) { + String hash = hashes.get(atom); + if (hash == null) + hash = wlBaseLabel(atom); + Integer cnt = counts.get(hash); + counts.put(hash, cnt == null ? 1 : cnt + 1); + } + List keys = new ArrayList<>(counts.keySet()); + Collections.sort(keys); + List parts = new ArrayList<>(); + for (String key : keys) + parts.add(key + "x" + counts.get(key)); + return String.join(";", parts); + } + + private static String wlBaseLabel(IAtom atom) { + return atomicNumber(atom) + + ":" + formalCharge(atom) + + ":" + bool(atom.isAromatic()) + + ":" + implicitHydrogen(atom); + } + + private static int implicitHydrogen(IAtom atom) { + return atom.getImplicitHydrogenCount() != null ? atom.getImplicitHydrogenCount() : 0; + } + + private static int atomicNumber(IAtom atom) { + return atom.getAtomicNumber() != null ? atom.getAtomicNumber() : -1; + } + + private static int formalCharge(IAtom atom) { + return atom.getFormalCharge() != null ? atom.getFormalCharge() : 0; + } + + private static int degree(IAtom atom, IAtomContainer container) { + if (container == null) + return 0; + return container.getConnectedBondsCount(atom); + } + + private static int bool(boolean value) { + return value ? 1 : 0; + } + + @ExceptionHandler({Exception.class, InvalidSmilesException.class, InvalidSmirksException.class, InvalidReactionMappingException.class}) public static ResponseEntity handleException(Exception ex, WebRequest request) { - if (ex instanceof InvalidSmilesException) { + if (ex instanceof InvalidSmirksException) { + return new ResponseEntity<>("" + + "400 - Invalid SMIRKS" + + "
" + + "

Invalid SMIRKS

" + + ex.getMessage() + + "
" + + "", + new HttpHeaders(), + HttpStatus.BAD_REQUEST); + } else if (ex instanceof InvalidReactionMappingException) { + return new ResponseEntity<>("" + + "400 - Reaction Mapping Error" + + "
" + + "

Reaction Mapping Error

" + + ex.getMessage() + + "
" + + "", + new HttpHeaders(), + HttpStatus.BAD_REQUEST); + } else if (ex instanceof InvalidSmilesException) { InvalidSmilesException ise = (InvalidSmilesException) ex; String mesg = ise.getMessage(); String disp = ""; diff --git a/cdkdepict-lib/src/main/java/org/openscience/cdk/app/MolOp.java b/cdkdepict-lib/src/main/java/org/openscience/cdk/app/MolOp.java index 0f242f4..ba5324e 100644 --- a/cdkdepict-lib/src/main/java/org/openscience/cdk/app/MolOp.java +++ b/cdkdepict-lib/src/main/java/org/openscience/cdk/app/MolOp.java @@ -6,238 +6,146 @@ package org.openscience.cdk.app; -import org.openscience.cdk.CDKConstants; import org.openscience.cdk.config.Elements; import org.openscience.cdk.interfaces.IAtom; import org.openscience.cdk.interfaces.IAtomContainer; import org.openscience.cdk.interfaces.IBond; -import org.openscience.cdk.renderer.generators.standard.StandardGenerator; -import org.openscience.cdk.sgroup.Sgroup; -import org.openscience.cdk.sgroup.SgroupType; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; public class MolOp { - private static int calcValence(IAtom atom) { - int v = atom.getImplicitHydrogenCount(); - for (IBond bond : atom.bonds()) { - IBond.Order order = bond.getOrder(); - if (order != null && order != IBond.Order.UNSET) - v += order.numeric(); - } - return v; - } - - private static boolean isDativeDonor(IAtom a, DativeBond opt) { - switch (a.getAtomicNumber()) { - case IAtom.N: - case IAtom.P: - return a.getFormalCharge() == 0 && calcValence(a) == 4; - case IAtom.O: - return a.getFormalCharge() == 0 && calcValence(a) == 3; - default: - return false; - } - } - - private static boolean isDativeAcceptor(IAtom a, DativeBond opt) { - if (Elements.isMetal(a)) - return true; - if (opt == DativeBond.Metals) - return false; - switch (a.getAtomicNumber()) { - case IAtom.B: - return a.getFormalCharge() == 0 && calcValence(a) == 4; - case IAtom.O: - return a.getFormalCharge() == 0 && calcValence(a) == 1; - default: - return false; - } - } - - private static boolean isPosDativeDonor(IAtom a, DativeBond opt) { - switch (a.getAtomicNumber()) { - case IAtom.N: - case IAtom.P: - return a.getFormalCharge() == +1 && calcValence(a) == 4; - case IAtom.O: - return a.getFormalCharge() == +1 && calcValence(a) == 3; - default: - return false; - } - } - - private static boolean isNegDativeAcceptor(IAtom a, DativeBond opt) { - if (a.getFormalCharge() != -1) - return false; - if (Elements.isMetal(a)) - return true; - if (opt == DativeBond.Metals) - return false; - switch (a.getAtomicNumber()) { - case IAtom.B: - return calcValence(a) == 4; - case IAtom.O: - return calcValence(a) == 1; - default: - return false; - } + private static int calcValence(IAtom atom) { + int v = atom.getImplicitHydrogenCount(); + for (IBond bond : atom.bonds()) { + IBond.Order order = bond.getOrder(); + if (order != null && order != IBond.Order.UNSET) + v += order.numeric(); } - - public static void perceiveRadicals(IAtomContainer mol) { - for (IAtom atom : mol.atoms()) { - int v; - Integer q = atom.getFormalCharge(); - if (q == null) q = 0; - if (atom.isAromatic()) - continue; - switch (atom.getAtomicNumber()) { - case 6: - if (q == 0) { - v = calcValence(atom); - if (v == 2) - mol.addSingleElectron(mol.indexOf(atom)); - if (v < 4) - mol.addSingleElectron(mol.indexOf(atom)); - } - break; - case 7: - if (q == 0) { - v = calcValence(atom); - if (v < 3) - mol.addSingleElectron(mol.indexOf(atom)); - } - break; - case 8: - if (q == 0) { - v = calcValence(atom); - if (v < 2) - mol.addSingleElectron(mol.indexOf(atom)); - if (v < 1) - mol.addSingleElectron(mol.indexOf(atom)); - } - break; - } - } + return v; + } + + private static boolean isDativeDonor(IAtom a, DativeBond opt) { + switch (a.getAtomicNumber()) { + case IAtom.N: + case IAtom.P: + return a.getFormalCharge() == 0 && calcValence(a) == 4; + case IAtom.O: + return a.getFormalCharge() == 0 && calcValence(a) == 3; + default: + return false; } - - enum DativeBond { - Always, - Metals, - Never + } + + private static boolean isDativeAcceptor(IAtom a, DativeBond opt) { + if (Elements.isMetal(a)) + return true; + if (opt == DativeBond.Metals) + return false; + switch (a.getAtomicNumber()) { + case IAtom.B: + return a.getFormalCharge() == 0 && calcValence(a) == 4; + case IAtom.O: + return a.getFormalCharge() == 0 && calcValence(a) == 1; + default: + return false; } - - public static void perceiveDativeBonds(IAtomContainer mol, DativeBond opt) { - if (opt == DativeBond.Never) - return; - for (IBond bond : mol.bonds()) { - IAtom beg = bond.getBegin(); - IAtom end = bond.getEnd(); - if (isPosDativeDonor(end, opt) && isNegDativeAcceptor(beg, opt)) { - bond.setDisplay(IBond.Display.ArrowBeg); - beg.setFormalCharge(beg.getFormalCharge() + 1); - end.setFormalCharge(end.getFormalCharge() - 1); - } else if (isPosDativeDonor(beg, opt) && isNegDativeAcceptor(end, opt)) { - bond.setDisplay(IBond.Display.ArrowEnd); - beg.setFormalCharge(beg.getFormalCharge() - 1); - end.setFormalCharge(end.getFormalCharge() + 1); - } - } - for (IBond bond : mol.bonds()) { - IAtom beg = bond.getBegin(); - IAtom end = bond.getEnd(); - if (isDativeDonor(end, opt) && isDativeAcceptor(beg, opt)) { - bond.setDisplay(IBond.Display.ArrowBeg); - } else if (isDativeDonor(beg, opt) && isDativeAcceptor(end, opt)) { - bond.setDisplay(IBond.Display.ArrowEnd); - } - } + } + + private static boolean isPosDativeDonor(IAtom a, DativeBond opt) { + switch (a.getAtomicNumber()) { + case IAtom.N: + case IAtom.P: + return a.getFormalCharge() == +1 && calcValence(a) == 4; + case IAtom.O: + return a.getFormalCharge() == +1 && calcValence(a) == 3; + default: + return false; } - - enum MulticenterStyle { - Provided, - Hidden, - Dashed, - Dative, - HiddenNeutral, - DashedNeutral + } + + private static boolean isNegDativeAcceptor(IAtom a, DativeBond opt) { + if (a.getFormalCharge() != -1) + return false; + if (Elements.isMetal(a)) + return true; + if (opt == DativeBond.Metals) + return false; + switch (a.getAtomicNumber()) { + case IAtom.B: + return calcValence(a) == 4; + case IAtom.O: + return calcValence(a) == 1; + default: + return false; } - - private static void setBondStyle(MulticenterStyle style, IBond bond) { - switch (style) { - case Hidden: - case HiddenNeutral: - bond.setProperty(StandardGenerator.HIDDEN, true); - break; - case Dashed: - case DashedNeutral: - bond.setDisplay(IBond.Display.Dash); - break; - case Dative: - if (bond.getBegin().getBondCount() == 1) - bond.setDisplay(IBond.Display.ArrowEnd); - else - bond.setDisplay(IBond.Display.ArrowBeg); - break; - } + } + + public static void perceiveRadicals(IAtomContainer mol) { + for (IAtom atom : mol.atoms()) { + int v; + Integer q = atom.getFormalCharge(); + if (q == null) q = 0; + if (atom.isAromatic()) + continue; + switch (atom.getAtomicNumber()) { + case 6: + if (q == 0) { + v = calcValence(atom); + if (v == 2) + mol.addSingleElectron(mol.indexOf(atom)); + if (v < 4) + mol.addSingleElectron(mol.indexOf(atom)); + } + break; + case 7: + if (q == 0) { + v = calcValence(atom); + if (v < 3) + mol.addSingleElectron(mol.indexOf(atom)); + } + break; + case 8: + if (q == 0) { + v = calcValence(atom); + if (v < 2) + mol.addSingleElectron(mol.indexOf(atom)); + if (v < 1) + mol.addSingleElectron(mol.indexOf(atom)); + } + break; + } } - - private static void neutralize(Set ringAtoms, IAtom metal, IAtom attach) { - int chargeOnRing = 0; - for (IAtom atom : ringAtoms) { - if (atom.equals(attach)) - continue; - if (atom.getFormalCharge() != null) - chargeOnRing += atom.getFormalCharge(); - atom.setIsAromatic(true); - for (IBond bond : atom.bonds()) { - if (ringAtoms.contains(bond.getOther(atom))) { - bond.setIsAromatic(true); - bond.setOrder(IBond.Order.UNSET); - } - } - } - if (metal.getFormalCharge() != null && - metal.getFormalCharge() >= -chargeOnRing) { - metal.setFormalCharge(metal.getFormalCharge() - -chargeOnRing); - for (IAtom atom : ringAtoms) - atom.setFormalCharge(0); - } + } + + enum DativeBond { + Always, + Metals, + Never + } + + public static void perceiveDativeBonds(IAtomContainer mol, DativeBond opt) { + if (opt == DativeBond.Never) + return; + for (IBond bond : mol.bonds()) { + IAtom beg = bond.getBegin(); + IAtom end = bond.getEnd(); + if (isPosDativeDonor(end, opt) && isNegDativeAcceptor(beg, opt)) { + bond.setDisplay(IBond.Display.ArrowBeg); + beg.setFormalCharge(beg.getFormalCharge() + 1); + end.setFormalCharge(end.getFormalCharge() - 1); + } else if (isPosDativeDonor(beg, opt) && isNegDativeAcceptor(end, opt)) { + bond.setDisplay(IBond.Display.ArrowEnd); + beg.setFormalCharge(beg.getFormalCharge() - 1); + end.setFormalCharge(end.getFormalCharge() + 1); + } } - - static void setMulticenterStyle(IAtomContainer mol, - MulticenterStyle style) { - if (style != MulticenterStyle.Provided) { - if (mol == null) - return; - List sgroups = mol.getProperty(CDKConstants.CTAB_SGROUPS); - if (sgroups == null) - return; - for (Sgroup sgroup : sgroups) { - if (sgroup.getType() != SgroupType.ExtMulticenter) - continue; - Set atoms = sgroup.getAtoms(); - Set bonds = sgroup.getBonds(); - if (bonds.size() != 1) - continue; - IBond bond = bonds.iterator().next(); - if (Elements.isMetal(bond.getBegin()) && atoms.contains(bond.getEnd())) { - setBondStyle(style, bond); - if (style == MulticenterStyle.Dative || - style == MulticenterStyle.DashedNeutral || - style == MulticenterStyle.HiddenNeutral) - neutralize(sgroup.getAtoms(), bond.getBegin(), bond.getEnd()); - } else if (Elements.isMetal(bond.getEnd()) && atoms.contains(bond.getBegin())) { - setBondStyle(style, bond); - if (style == MulticenterStyle.Dative || - style == MulticenterStyle.DashedNeutral || - style == MulticenterStyle.HiddenNeutral) - neutralize(sgroup.getAtoms(), bond.getEnd(), bond.getBegin()); - } - } - } + for (IBond bond : mol.bonds()) { + IAtom beg = bond.getBegin(); + IAtom end = bond.getEnd(); + if (isDativeDonor(end, opt) && isDativeAcceptor(beg, opt)) { + bond.setDisplay(IBond.Display.ArrowBeg); + } else if (isDativeDonor(beg, opt) && isDativeAcceptor(end, opt)) { + bond.setDisplay(IBond.Display.ArrowEnd); + } } + } } diff --git a/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/group_abbr.smi b/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/group_abbr.smi index dd6d30d..e48de78 100644 --- a/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/group_abbr.smi +++ b/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/group_abbr.smi @@ -47,13 +47,9 @@ CC(C)(C)OC(*)=O Boc *Cc1ccccc1 Bn *c1ccccc1 Ph *C1CCCCC1 Cy -*C([2H])([2H])[2H] CD3 -*OC([2H])([2H])[2H] OCD3 *OP(=O)(O)O OPO3H2 *OP(=O)([O-])[O-] OPO3-2 -*OP(=O)([OH])[O-] OPOH3H- -*OP(=O)([O-])OP(=O)([OH])[O-] OP2O6H-2 -*OP(=O)([O-])OP(=O)([O-])OP(=O)([OH])[O-] OP3O9H-3 +*OP(=O)([OH])[O-] OPO3- *P(=O)(O)O PO3H2 *P(=O)([O-])[O-] PO3-2 *P(=O)([OH])[O-] PO3- diff --git a/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/reagent_abbr.smi b/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/reagent_abbr.smi index daec25b..32ea250 100644 --- a/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/reagent_abbr.smi +++ b/cdkdepict-lib/src/main/resources/org/openscience/cdk/app/reagent_abbr.smi @@ -108,7 +108,6 @@ O=P(O)(O[K])O[K] K2HPO4 [O-]P([O-])(=O)O.[K+].[K+] K2HPO4 O=P(O[K])(O[K])O[K] K3PO4 [O-]P([O-])([O-])=O.[K+].[K+].[K+] K3PO4 -N#C[Fe-4](C#N)(C#N)(C#N)(C#N)C#N.[K+].[K+].[K+].[K+] K4Fe(CN)6 Br[K] KBr [Br-].[K+] KBr N#C[K] KCN @@ -284,7 +283,6 @@ CCCC[N+](CCCC)(CCCC)CCCC.[F-] TBAF CCCC[N+](CCCC)(CCCC)CCCC.[I-] TBAI CN(C)C(On1nnc2ccccc21)=[N+](C)C.F[B-](F)(F)F TBTU CC1(CCCC(N1[O])(C)C)C TEMPO -ClC(Cl)Cl TCM O=C(O)C(F)(F)F TFA O=C(OC(=O)C(F)(F)F)C(F)(F)F TFAA C1CCOC1 THF @@ -333,7 +331,6 @@ CC(C)(C)[O-].[K+] tBuOK CC(C)(C)[PH+](C(C)(C)C)C(C)(C)C.F[B-](F)(F)F TTBP·HBF4 C(#N)C=P(CCCC)(CCCC)CCCC CMBP CCCP1(=O)OP(=O)(CCC)OP(=O)(CCC)O1 T3P -[2H]C([2H])([2H])O[2H] CD3OD # Metabolites Nc1ncnc2n(cnc12)[C@@H]1O[C@H](COP(O)(=O)OP(O)(=O)OP(O)(O)=O)[C@@H](O)[C@H]1O ATP diff --git a/cdkdepict-lib/src/test/java/org/openscience/cdk/app/DepictControllerTest.java b/cdkdepict-lib/src/test/java/org/openscience/cdk/app/DepictControllerTest.java index 291166c..833795a 100644 --- a/cdkdepict-lib/src/test/java/org/openscience/cdk/app/DepictControllerTest.java +++ b/cdkdepict-lib/src/test/java/org/openscience/cdk/app/DepictControllerTest.java @@ -5,10 +5,20 @@ package org.openscience.cdk.app; import org.junit.jupiter.api.Test; -import static org.hamcrest.Matchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; class DepictControllerTest { @@ -39,4 +49,186 @@ void getColorTooLong() throws Exception { assertThat(color.getAlpha(), is(255)); } -} \ No newline at end of file + @Test + void colmapReactionRenders() throws Exception { + DepictController controller = new DepictController(); + Map extra = new HashMap<>(); + extra.put("annotate", "colmap"); + HttpEntity response = controller.depict("[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]", + "svg", + "bot", + extra); + assertThat(response.getBody(), is(instanceOf(byte[].class))); + String svg = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(svg, containsString(" extra = new HashMap<>(); + extra.put("annotate", "rxnchg"); + HttpEntity response = controller.depict("[CH3:1][CH2:2]Cl>>[CH3:1][CH2:2]Br", + "svg", + "bot", + extra); + assertThat(response.getBody(), is(instanceOf(byte[].class))); + String svg = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(svg, containsString(" extra = new HashMap<>(); + extra.put("annotate", "rxnchg"); + HttpEntity response = controller.depict("CCO", + "svg", + "bot", + extra); + assertThat(response.getBody(), is(instanceOf(byte[].class))); + String svg = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(svg, containsString(" response = controller.react("CCO", + "[C:1]-[O:2]>>[C:1]-[N:2]", + "svg", + "bot", + new HashMap<>()); + assertThat(response.getBody(), is(instanceOf(byte[].class))); + String body = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(body, containsString(" forward = controller.react("CCN", + "[C:1]-[O:2]>>[C:1]-[N:2]", + "svg", + "bot", + new HashMap<>()); + String forwardBody = new String((byte[]) forward.getBody(), StandardCharsets.UTF_8); + assertThat(forwardBody, containsString("No products generated")); + + Map extra = new HashMap<>(); + extra.put("reverse", "true"); + HttpEntity reverse = controller.react("CCN", + "[C:1]-[O:2]>>[C:1]-[N:2]", + "svg", + "bot", + extra); + String reverseBody = new String((byte[]) reverse.getBody(), StandardCharsets.UTF_8); + assertThat(reverseBody, containsString(" deduped = controller.transformProductSmiles("CCC", + "[C:1]>>[C:1]", + false); + assertThat(deduped.size(), is(1)); + + StringBuilder smi = new StringBuilder("O"); + for (int i = 0; i < 120; i++) { + smi.append('C'); + } + List capped = controller.transformProductSmiles(smi.toString(), + "[C:1]>>[N:1]", + false); + assertThat(capped.size(), is(DepictController.MAX_REACTION_PRODUCTS)); + } + + @Test + void reactNoMatchReturnsMessage() throws Exception { + DepictController controller = new DepictController(); + HttpEntity response = controller.react("CCO", + "[N:1]>>[O:1]", + "svg", + "bot", + new HashMap<>()); + String body = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(body, containsString("No products generated")); + } + + @Test + void mapReactionSmilesReturnsMappedReaction() throws Exception { + DepictController controller = new DepictController(); + String mapped = controller.mapReactionSmiles("CC(=O)O.OCC>>CC(=O)OCC.O"); + assertThat(mapped, containsString(">>")); + assertThat(mapped, containsString(":")); + } + + @Test + void mapReactionSmilesWithAgentsReturnsMappedReaction() throws Exception { + DepictController controller = new DepictController(); + String mapped = controller.mapReactionSmiles("CCO.[CH3:1][C:2](=[O:3])[OH:4]>[H+]>CC[O:4][C:2](=[O:3])[CH3:1].O"); + assertThat(mapped, containsString(">[H+]>")); + assertThat(mapped, containsString(":")); + } + + @Test + void mapEndpointRendersMappedReaction() throws Exception { + DepictController controller = new DepictController(); + HttpEntity response = controller.map("CC(=O)O.OCC>>CC(=O)OCC.O", + "svg", + "bot", + new HashMap<>()); + assertThat(response.getBody(), is(instanceOf(byte[].class))); + String body = new String((byte[]) response.getBody(), StandardCharsets.UTF_8); + assertThat(body, containsString(" controller.mapReactionSmiles("CCO")); + + ResponseEntity response = DepictController.handleException(ex, null); + assertThat(response.getStatusCode(), is(HttpStatus.BAD_REQUEST)); + String body = response.getBody().toString(); + assertThat(body, containsString("Reaction Mapping Error")); + assertThat(body, containsString("reactants>agents>products")); + } + + @Test + void invalidSmirksReturnsBadRequest() throws Exception { + DepictController controller = new DepictController(); + Exception ex = assertThrows(Exception.class, + () -> controller.react("CCO", + "[C:1]>>[N:1", + "svg", + "bot", + new HashMap<>())); + + ResponseEntity response = DepictController.handleException(ex, null); + assertThat(response.getStatusCode(), is(HttpStatus.BAD_REQUEST)); + String body = response.getBody().toString(); + assertThat(body, containsString("Invalid SMIRKS")); + } + + @Test + void invalidSmilesInReactReturnsBadRequest() throws Exception { + DepictController controller = new DepictController(); + Exception ex = assertThrows(Exception.class, + () -> controller.react("C1CC", + "[C:1]>>[N:1]", + "svg", + "bot", + new HashMap<>())); + + ResponseEntity response = DepictController.handleException(ex, null); + assertThat(response.getStatusCode(), is(HttpStatus.BAD_REQUEST)); + String body = response.getBody().toString(); + assertThat(body, containsString("Invalid SMILES")); + } + +} diff --git a/cdkdepict-lib/src/test/java/org/openscience/cdk/app/ReactionCenterDetectionTest.java b/cdkdepict-lib/src/test/java/org/openscience/cdk/app/ReactionCenterDetectionTest.java new file mode 100644 index 0000000..5133417 --- /dev/null +++ b/cdkdepict-lib/src/test/java/org/openscience/cdk/app/ReactionCenterDetectionTest.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026. + */ + +package org.openscience.cdk.app; + +import org.junit.jupiter.api.Test; +import org.openscience.cdk.CDKConstants; +import org.openscience.cdk.interfaces.IAtom; +import org.openscience.cdk.interfaces.IAtomContainer; +import org.openscience.cdk.interfaces.IBond; +import org.openscience.cdk.interfaces.IReaction; +import org.openscience.cdk.interfaces.IReactionSet; +import org.openscience.cdk.silent.SilentChemObjectBuilder; +import org.openscience.cdk.smiles.SmilesParser; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.is; + +class ReactionCenterDetectionTest { + + @Test + void mappedBondOrderChangeMarksMappedAtoms() throws Exception { + IReaction rxn = parseReaction("[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(mappedIndices(centers), is(new HashSet<>(Arrays.asList(1, 2)))); + } + + @Test + void mappedConnectivityChangeMarksMappedAtoms() throws Exception { + IReaction rxn = parseReaction("[CH3:1][CH2:2][Cl:3].[O-:4]>>[CH3:1][CH2:2][O-:4].[Cl-:3]"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(mappedIndices(centers), is(new HashSet<>(Arrays.asList(2, 3, 4)))); + } + + @Test + void unchangedMappedAtomsAreNotMarked() throws Exception { + IReaction rxn = parseReaction("[CH3:1][CH3:2]>>[CH3:1][CH3:2]"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(centers.isEmpty(), is(true)); + } + + @Test + void partiallyMappedReactionUsesFallbackForUnmappedAtoms() throws Exception { + IReaction rxn = parseReaction("[CH3:1][CH2:2]Cl>>[CH3:1][CH2:2]Br"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(mappedIndices(centers), is(new HashSet<>(Arrays.asList(2)))); + assertThat(unmappedAtomicNumbers(centers), is(new HashSet<>(Arrays.asList(17, 35)))); + } + + @Test + void fullyUnmappedSameReactionHasNoCenters() throws Exception { + IReaction rxn = parseReaction("CCO>>CCO"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(centers.isEmpty(), is(true)); + } + + @Test + void fullyUnmappedTransformedReactionMarksUnmatchedHashes() throws Exception { + IReaction rxn = parseReaction("CCO>>CCN"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(centers.isEmpty(), is(false)); + assertThat(unmappedAtomicNumbers(centers), hasItems(7, 8)); + } + + @Test + void aromaticKekuleShiftDoesNotMarkCenters() throws Exception { + IReaction rxn = parseReaction("[c:1]1[c:2][c:3][c:4][c:5][c:6]1>>[c:1]1[c:2][c:3][c:4][c:5][c:6]1"); + IAtomContainer product = rxn.getProducts().getAtomContainer(0); + int i = 0; + for (IBond bond : product.bonds()) { + bond.setOrder((i++ & 1) == 0 ? IBond.Order.SINGLE : IBond.Order.DOUBLE); + bond.setIsAromatic(false); + bond.getBegin().setIsAromatic(true); + bond.getEnd().setIsAromatic(true); + } + + Set centers = DepictController.findReactionCenterAtoms(rxn); + assertThat(centers.isEmpty(), is(true)); + } + + @Test + void fallbackPrefersComponentWiseMatchingForUnmappedAtoms() throws Exception { + IReaction rxn = parseReaction("c1ccccc1.COc1ccccc1>>c1ccccc1.Nc1ccccc1"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + + IAtomContainer unchangedReactant = rxn.getReactants().getAtomContainer(0); + IAtomContainer unchangedProduct = rxn.getProducts().getAtomContainer(0); + + assertThat(anyCenterInContainer(centers, unchangedReactant), is(false)); + assertThat(anyCenterInContainer(centers, unchangedProduct), is(false)); + assertThat(centers.isEmpty(), is(false)); + } + + @Test + void fallbackAvoidsAmbiguousScatteredSelections() throws Exception { + IReaction rxn = parseReaction("c1ccccc1>>Cc1ccccc1"); + Set centers = DepictController.findReactionCenterAtoms(rxn); + + IAtomContainer product = rxn.getProducts().getAtomContainer(0); + int aromaticCenters = 0; + int nonAromaticCenters = 0; + for (IAtom atom : product.atoms()) { + if (!centers.contains(atom)) + continue; + if (atom.isAromatic()) + aromaticCenters++; + else + nonAromaticCenters++; + } + + assertThat(nonAromaticCenters >= 1, is(true)); + assertThat(aromaticCenters, is(0)); + } + + private static IReaction parseReaction(String smiles) throws Exception { + SmilesParser parser = new SmilesParser(SilentChemObjectBuilder.getInstance()); + IReactionSet set = parser.parseReactionSetSmiles(smiles); + return set.reactions().iterator().next(); + } + + private static Set mappedIndices(Set atoms) { + Set idxs = new HashSet<>(); + for (IAtom atom : atoms) { + int map = mapIdx(atom); + if (map > 0) + idxs.add(map); + } + return idxs; + } + + private static Set unmappedAtomicNumbers(Set atoms) { + Set nums = new HashSet<>(); + for (IAtom atom : atoms) { + if (mapIdx(atom) == 0 && atom.getAtomicNumber() != null) + nums.add(atom.getAtomicNumber()); + } + return nums; + } + + private static int mapIdx(IAtom atom) { + if (atom.getMapIdx() > 0) + return atom.getMapIdx(); + Object val = atom.getProperty(CDKConstants.ATOM_ATOM_MAPPING); + if (val instanceof Number) + return ((Number) val).intValue(); + if (val instanceof String) { + try { + return Integer.parseInt((String) val); + } catch (NumberFormatException ignored) { + return 0; + } + } + return 0; + } + + private static boolean anyCenterInContainer(Set centers, IAtomContainer container) { + for (IAtom atom : container.atoms()) { + if (centers.contains(atom)) + return true; + } + return false; + } +} diff --git a/cdkdepict-webapp/pom.xml b/cdkdepict-webapp/pom.xml index 8ecb817..51abdc8 100644 --- a/cdkdepict-webapp/pom.xml +++ b/cdkdepict-webapp/pom.xml @@ -5,7 +5,7 @@ cdkdepict org.openscience.cdk - 1.15 + 1.14 war 4.0.0 @@ -23,7 +23,7 @@ org.webjars webjars-locator-core - 0.59 + 0.58 org.webjars @@ -33,7 +33,7 @@ org.webjars font-awesome - 6.7.2 + 6.5.2 @@ -43,6 +43,7 @@ maven-war-plugin 3.2.0 + src/main/webapp/WEB-INF/web-jakarta.xml true @@ -101,7 +102,7 @@ org.apache.logging.log4j log4j-slf4j-impl - 2.25.4 + 2.20.0 @@ -143,5 +144,24 @@ + + ossrh + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.3 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + diff --git a/cdkdepict-webapp/src/main/spring-boot/org/openscience/cdk/app/Application.java b/cdkdepict-webapp/src/main/spring-boot/org/openscience/cdk/app/Application.java index 55301b8..60968b7 100644 --- a/cdkdepict-webapp/src/main/spring-boot/org/openscience/cdk/app/Application.java +++ b/cdkdepict-webapp/src/main/spring-boot/org/openscience/cdk/app/Application.java @@ -8,9 +8,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; @SpringBootApplication -public class Application { +public class Application extends SpringBootServletInitializer { + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(Application.class); + } + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/css/depict.css b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/css/depict.css index 668ba0b..24319c8 100644 --- a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/css/depict.css +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/css/depict.css @@ -6,10 +6,6 @@ body { color: #222; } -body#react { - background-color: #ad5368; -} - .center { margin: auto; max-width: 960px; @@ -230,31 +226,44 @@ i.icon { border: none; } -textarea#input { +textarea#input, +textarea#react_input, +textarea#map_input { width: 100%; min-height: 40px; height: 125px; resize: vertical; } -#inputs { - display: grid; - grid-template-columns: auto max-content auto; - grid-gap: 5px; - align-items: center; +.map-meta { + grid-area: bottom; + text-align: left; + margin-top: 6px; } -#inputs input { - grid-column: span 3; - font-family: Menlo, Courier, Fixed-Width; +.map-meta .title { + margin-bottom: 6px; } -#inputs textarea { - min-height: 40px; - height: 125px; - resize: none; +.map-output-label { + display: block; + font-size: smaller; + margin-bottom: 4px; } +.map-output { + width: 100%; + min-height: 64px; + box-sizing: border-box; + font-family: Courier, "Fixed Width", monospace; + font-size: 9pt; +} + +.map-loading { + padding: 24px 12px; + font-size: smaller; + color: #666; +} #sma { width: 249px; @@ -269,4 +278,4 @@ textarea { textarea { padding: 5px; -} \ No newline at end of file +} diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/depict.html b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/depict.html index aef52c2..10954e0 100644 --- a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/depict.html +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/depict.html @@ -16,6 +16,11 @@
Generate depictions of molecules and reactions from SMILES or SDF. +
+ Depict | + React | + Map +
Fork me on GitHub
@@ -42,18 +47,20 @@ @@ -98,17 +105,6 @@ - Multicenter - - - Embed Title @@ -129,10 +125,6 @@ - Donuts - - -
diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/depict.js b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/depict.js index e4e67ee..60ae400 100644 --- a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/depict.js +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/depict.js @@ -16,9 +16,7 @@ function smittl(smiles) { i = j; if (i < 0) return ''; // no title - var prelim = smiles.substring(i+1); - // remove anything that looks like CXSMILES layers from the start - return prelim.replace(/^\|.+\|($|\s)/, ""); + return smiles.substring(i+1); } function renderSMILES(inputs, opts) { @@ -37,6 +35,7 @@ function renderSMILES(inputs, opts) { continue var title = smittl(input); + title = title.replace(/^\|[^|]+\|\s+/, ""); if (!title) title = "#" + (1+i); @@ -80,9 +79,7 @@ function update() { 'showtitle': $("input[name='showtitle']").is(':checked'), 'abbr': $("select[name='abbr'] option:selected").val(), 'arw': $("select[name='arw'] option:selected").val(), - 'dat': $("select[name='dat'] option:selected").val(), - 'dnt': $("input[name='donuts']").is(':checked'), - 'mc': $("select[name='mc'] option:selected").val(), + 'dat': $("select[name='dat'] option:selected").val() }; result.removeClass().addClass(opts.style); @@ -124,10 +121,6 @@ function depict_url(opts, smiles, fmt, w, h) { url += '&f=1'; if (opts.rotate) url += '&r=' + encodeURIComponent(opts.rotate); - if (opts.dnt) - url += '&dnt=1'; - if (opts.mc != 'p') - url += '&mc=' + encodeURIComponent(opts.mc); return url; } @@ -164,7 +157,7 @@ function generate(opts, smiles, title) { } function handle_img_error(img) { - $.ajax($(img).attr('src')).fail(function(r){ + $.ajax($(img).attr('src')).error(function(r){ reason = r.responseText; var tempDom = $('').append($.parseHTML(reason)); console.log($('div', tempDom).html()); diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/map.js b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/map.js new file mode 100644 index 0000000..34b6126 --- /dev/null +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/js/map.js @@ -0,0 +1,188 @@ +var ROOT_URL = "."; +var mapUpdateSerial = 0; + +function clearMapInput() { + $('#map_input').val(''); + updateMap(); +} + +function toggleMapExtraOpts() { + $('#depict_extra_opts').slideToggle(); +} + +function smittl(smiles) { + var i = smiles.indexOf(' '); + var j = smiles.indexOf('\t'); + if (j >= 0 && (j < i || i < 0)) + i = j; + if (i < 0) + return ''; + return smiles.substring(i + 1); +} + +function mapSmilesUrl(smiles) { + return ROOT_URL + '/map/smi?smi=' + encodeURIComponent(smiles); +} + +function mapUrl(opts, smiles, fmt, w, h) { + var smi = encodeURIComponent(smiles); + var url = ROOT_URL + '/map/' + opts.style + '/' + fmt + '?smi=' + smi; + if (w && h) + url += '&w=' + w + '&h=' + h; + url += '&abbr=' + opts.abbr; + url += '&hdisp=' + opts.hdisp; + if (opts.showtitle) + url += '&showtitle=' + opts.showtitle; + if (opts.sma) + url += '&sma=' + encodeURIComponent(opts.sma); + if (opts.zoom && opts.zoom !== 130) + url += '&zoom=' + encodeURIComponent(opts.zoom / 100); + if (opts.annotate) + url += '&annotate=' + encodeURIComponent(opts.annotate); + if (opts.arw) + url += '&arw=' + encodeURIComponent(opts.arw); + if (opts.dat !== 'm') + url += '&dat=' + encodeURIComponent(opts.dat); + if (opts.flip) + url += '&f=1'; + if (opts.rotate) + url += '&r=' + encodeURIComponent(opts.rotate); + return url; +} + +function renderMapSMILES(inputs, opts, updateId) { + var result = []; + var nlines = inputs.length < 500 ? inputs.length : 500; + if (inputs.length > 500) { + alert("Only the first 500 entries will be displayed!"); + } + + for (var i = 0; i < nlines; i++) { + var input = inputs[i].trim(); + + if (input.length === 0 || input.charAt(0) === '#') + continue; + + var title = smittl(input); + title = title.replace(/^\|[^|]+\|\s+/, ""); + if (!title) + title = "#" + (1 + i); + + result.push(renderMapResult(opts, input, title, updateId)); + } + + return result; +} + +function updateMap() { + mapUpdateSerial++; + var updateId = mapUpdateSerial; + var input = $('#map_input').val(); + var result = $('#result'); + result.empty(); + + var opts = { + 'style': $("select[name='style'] option:selected").val(), + 'annotate': $("select[name='annotate'] option:selected").val(), + 'zoom': $("input[name='zoom']").val(), + 'flip': $("input[name='flip']").is(':checked'), + 'rotate': $("input[name='rotate']").val(), + 'sma': $("input[name='smarts']").val(), + 'hdisp': $("select[name='hdisp'] option:selected").val(), + 'showtitle': $("input[name='showtitle']").is(':checked'), + 'abbr': $("select[name='abbr'] option:selected").val(), + 'arw': $("select[name='arw'] option:selected").val(), + 'dat': $("select[name='dat'] option:selected").val() + }; + + result.removeClass().addClass(opts.style); + + if (!input) + return; + + result.append(renderMapSMILES(input.split("\n"), opts, updateId)); +} + +function renderMapResult(opts, smiles, title, updateId) { + var $outer = $('
').addClass('chemdiv').addClass('scheme'); + var $img = $('
').append($('
').text('Mapping reaction...')); + var $div = $('
').append($img); + var $meta = $('
'); + if (!opts.showtitle) + $meta.append($('
').append(title)); + $meta.append($('
+ + + diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/static/react.html b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/react.html new file mode 100644 index 0000000..2688098 --- /dev/null +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/static/react.html @@ -0,0 +1,143 @@ + + + + CDK React + + + + + + + + +
+ +
+
+
+ Apply a reaction SMIRKS transformation to one input molecule and depict all generated reactions. +
+ Depict | + React | + Map +
+ Fork me on GitHub +
+ + + + +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Reaction + + Dative Bonds + + Reverse Rule + +
Embed Title + + Zoom + + Rotate + +
Flip + +
+
+ +
+
+ +
+ + + diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/web-javaee.xml b/cdkdepict-webapp/src/main/webapp/WEB-INF/web-jakarta.xml similarity index 86% rename from cdkdepict-webapp/src/main/webapp/WEB-INF/web-javaee.xml rename to cdkdepict-webapp/src/main/webapp/WEB-INF/web-jakarta.xml index 822f14d..7dabb5e 100644 --- a/cdkdepict-webapp/src/main/webapp/WEB-INF/web-javaee.xml +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/web-jakarta.xml @@ -1,9 +1,9 @@ - + xmlns="https://jakarta.ee/xml/ns/jakartaee" + xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd" + version="6.0"> + cdkdepict org.springframework.web.servlet.DispatcherServlet diff --git a/cdkdepict-webapp/src/main/webapp/WEB-INF/web.xml b/cdkdepict-webapp/src/main/webapp/WEB-INF/web.xml index f03ddf4..822f14d 100644 --- a/cdkdepict-webapp/src/main/webapp/WEB-INF/web.xml +++ b/cdkdepict-webapp/src/main/webapp/WEB-INF/web.xml @@ -1,10 +1,9 @@ - + xmlns="http://xmlns.jcp.org/xml/ns/javaee" + xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" + version="4.0"> + cdkdepict org.springframework.web.servlet.DispatcherServlet diff --git a/docker/Dockerfile b/docker/Dockerfile index 5642a16..66fda54 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,15 +1,16 @@ -FROM maven:3.9.3-eclipse-temurin-17-alpine as builder +FROM maven:3.5-jdk-8-alpine as builder WORKDIR /usr/src/app COPY cdkdepict-lib cdkdepict-lib COPY cdkdepict-webapp cdkdepict-webapp COPY pom.xml pom.xml -RUN mvn clean package -Pbootable +RUN mvn clean package -FROM eclipse-temurin:17-jre-alpine as runtime +FROM eclipse-temurin:8u352-b08-jre-alpine as runtime # we need a font! DejaVu does fine but we only want the Sans-Serif one RUN apk add --no-cache libgcc && \ rm /usr/share/fonts/dejavu/DejaVuSerif* \ + /usr/share/fonts/dejavu/DejaVuLGC* \ /usr/share/fonts/dejavu/DejaVuSansCond* \ /usr/share/fonts/dejavu/DejaVuSansMono* \ /usr/share/fonts/dejavu/DejaVuSans-* \ diff --git a/docker/Dockerfile.javaee b/docker/Dockerfile.jakarta similarity index 80% rename from docker/Dockerfile.javaee rename to docker/Dockerfile.jakarta index 9118c06..b18a3c7 100644 --- a/docker/Dockerfile.javaee +++ b/docker/Dockerfile.jakarta @@ -1,16 +1,15 @@ -FROM maven:3.5-jdk-8-alpine as builder +FROM maven:3.9.3-eclipse-temurin-17-alpine as builder WORKDIR /usr/src/app COPY cdkdepict-lib cdkdepict-lib COPY cdkdepict-webapp cdkdepict-webapp COPY pom.xml pom.xml -RUN mvn clean package -Pjavaee +RUN mvn clean package -Pbootable,jakarta -FROM eclipse-temurin:8u352-b08-jre-alpine as runtime +FROM eclipse-temurin:17-jre-alpine as runtime # we need a font! DejaVu does fine but we only want the Sans-Serif one RUN apk add --no-cache libgcc && \ rm /usr/share/fonts/dejavu/DejaVuSerif* \ - /usr/share/fonts/dejavu/DejaVuLGC* \ /usr/share/fonts/dejavu/DejaVuSansCond* \ /usr/share/fonts/dejavu/DejaVuSansMono* \ /usr/share/fonts/dejavu/DejaVuSans-* \ diff --git a/docker/README.md b/docker/README.md index eadbe0b..ec0e70d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,20 +2,18 @@ To build the 'cdk/depict' image directly from source (including the MAVEN build). -There are two different versions, the newer "jakarta" version uses Spring 6 (default) and requires Java 17/TomCat 10 to deploy. The "javaee" uses the older -APIs and will run on Java 8/TomCat 9 but uses libraries which are no longer -updated. +There are two different versions, the newer "jakarta" version uses Spring 6 and requires Java 17/TomCat 10 to deploy. ``` -docker/$ docker build -t cdkdepict -f Dockerfile .. -docker/$ docker build -t cdkdepict-javaee -f Dockerfile.javaee .. +docker/$ docker build -t cdkdepict-jsp3 -f Dockerfile .. +docker/$ docker build -t cdkdepict-jakarta -f Dockerfile.jakarta .. ``` or from the project main directory: ``` $ docker build -t cdkdepict -f docker/Dockerfile . -$ docker build -t cdkdepict-javaee -f docker/Dockerfile.javaee . +$ docker build -t cdkdepict-jakarta -f docker/Dockerfile.jakarta . ``` # Run diff --git a/docs/RDT_CDK_INTEGRATION.md b/docs/RDT_CDK_INTEGRATION.md new file mode 100644 index 0000000..5db5985 --- /dev/null +++ b/docs/RDT_CDK_INTEGRATION.md @@ -0,0 +1,239 @@ +# Integrating RDT (Reaction Decoder Tool) into a CDK application + +This note is for developers (or coding agents) who embed **RDT** (`com.bioinceptionlabs:rdt`) in another **Java** app that already uses the **Chemistry Development Kit (CDK)**. It covers atom–atom mapping from **reaction SMILES** and **MDL RXN**, and how to **detect or tag changing bonds** on real `IBond` / `IAtom` objects. + +RDT is **deterministic** and does **not** require machine-learning models. It depends on CDK (same major line as declared in RDT’s `pom.xml`, currently **2.12**) and **SMSD** for subgraph / MCS work during mapping. + +--- + +## 1. Add the dependency + +**Maven:** + +```xml + + com.bioinceptionlabs + rdt + 4.0.0 + +``` + +**Gradle (after `mvn install` to `~/.m2` or a reachable repository):** + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} +dependencies { + implementation("com.bioinceptionlabs:rdt:4.0.0") +} +``` + +**Java version:** RDT’s build targets **Java 25** in this repository. Your app should use a compatible JDK, or align toolchains with the RDT artifact you depend on. + +**CDK alignment:** Prefer using the **same CDK 2.x line** RDT was built against to avoid subtle classpath conflicts. If you must mix versions, test thoroughly. + +--- + +## 2. Two integration styles + +| Style | When to use | You get | +|--------|-------------|--------| +| **Facade API** (`RDT.map`) | Quick integration, minimal CDK surface | `ReactionResult`: mapped SMILES, bond-change **strings**, counts, fingerprints, canonical signature | +| **Full CDK pipeline** (`ReactionMechanismTool`) | You already have `IReaction`, need `IBond`/`IAtom` tagging | `BondChangeCalculator` on the **mapped** reaction, lists of `BondChange`, reaction-center atoms, stereo lists | + +Both ultimately run **`ReactionMechanismTool`**; the facade just parses SMILES and packages outputs into `ReactionResult`. + +--- + +## 3. Map reaction SMILES (facade) + +```java +import com.bioinceptionlabs.reactionblast.api.RDT; +import com.bioinceptionlabs.reactionblast.api.ReactionResult; + +ReactionResult result = RDT.map("CC(=O)O.OCC>>CC(=O)OCC.O"); +// Optional: RDT.map(smiles, generate2D, complexMapping); // complex = ring-heavy cases + +if (result.isMapped()) { + String mapped = result.getMappedSmiles(); // AAM in SMILES + int total = result.getTotalBondChanges(); + // String-level features (good for logs, UI, ML-free similarity) + var formedCleaved = result.getFormedCleavedBonds(); + var orderChanges = result.getOrderChangedBonds(); + var stereo = result.getStereoChangedBonds(); + var centre = result.getReactionCentreFingerprint(); + String signature = result.getReactionSignature(); // canonical R-string-style summary + String algo = result.getAlgorithm(); // e.g. RINGS, MIN, MAX, MIXTURE +} +``` + +`ReactionResult` is **immutable** and avoids tying your UI layer to CDK types. Use **`RDT.compare(smiles1, smiles2)`** if you only need a **Tanimoto-style** similarity on bond-change fingerprints. + +--- + +## 4. Map from CDK: reaction SMILES + +Use this when you already build or receive `IReaction` (e.g. from your own parsers). + +```java +import org.openscience.cdk.interfaces.IReaction; +import org.openscience.cdk.silent.SilentChemObjectBuilder; +import org.openscience.cdk.smiles.SmilesParser; +import com.bioinceptionlabs.reactionblast.mechanism.ReactionMechanismTool; +import com.bioinceptionlabs.reactionblast.tools.StandardizeReaction; + +SmilesParser sp = new SmilesParser(SilentChemObjectBuilder.getInstance()); +IReaction reaction = sp.parseReactionSmiles("CC>>CC"); +reaction.setID("my_rxn"); + +ReactionMechanismTool rmt = new ReactionMechanismTool( + reaction, + true, // forcedMapping: recompute mapping even if present + true, // generate2D: perceive stereo for 2D + false, // generate3D + true, // checkComplex: ring / harder cases (more expensive) + false, // accept_no_change: set true for transporter-like “no bond change” cases + new StandardizeReaction()); +``` + +Typical `ReactionMechanismTool` constructor parameters: + +- **`forcedMapping`**: `true` = always run RDT mapping; `false` = can **reuse** existing atom–atom mappings on the reaction if they look complete. +- **`checkComplex`**: `true` = enable strategies tuned for **ring systems** (CLI `-c`); slower but broader coverage. +- **`accept_no_change`**: `true` = allow solutions with **no bond-order change** (e.g. transport); `false` = chemistry-style mapping only. + +If stoichiometry is **unbalanced**, RDT may **skip** mapping unless `forcedMapping` is `true` (see implementation logs / behaviour in `ReactionMechanismTool`). + +--- + +## 5. Map from MDL RXN (V2000) + +RDT ships **`MDLRXNV2000Reader`** under `com.bioinceptionlabs.reactionblast.tools.ChemicalFileIO`. Read an RXN file into CDK’s `IReaction`, then pass it to `ReactionMechanismTool` as above. + +```java +import java.io.FileReader; +import org.openscience.cdk.Reaction; +import com.bioinceptionlabs.reactionblast.tools.ChemicalFileIO.MDLRXNV2000Reader; + +IReaction reaction; +try (MDLRXNV2000Reader reader = new MDLRXNV2000Reader(new FileReader("reaction.rxn"))) { + reaction = reader.read(new Reaction()); +} +reaction.setId("from_rxn"); + +ReactionMechanismTool rmt = new ReactionMechanismTool( + reaction, true, true, false, true, false, new StandardizeReaction()); +``` + +Round-tripping via reaction SMILES (as in `ChemicalFormatParser` in this repo) is optional; for integration, feeding **`IReaction` directly** is usually enough. + +--- + +## 6. Detect and tag changing bonds (CDK objects) + +After mapping, read the **selected** solution and its **`BondChangeCalculator`**: + +```java +import com.bioinceptionlabs.reactionblast.mechanism.MappingSolution; +import com.bioinceptionlabs.reactionblast.mechanism.BondChangeCalculator; +import com.bioinceptionlabs.reactionblast.mechanism.MechanismHelpers.BondChange; +import org.openscience.cdk.interfaces.IBond; +import org.openscience.cdk.interfaces.IAtom; + +MappingSolution solution = rmt.getSelectedSolution(); +if (solution == null) { + // mapping failed or was skipped (e.g. unbalanced reaction with forcedMapping false) + return; +} +BondChangeCalculator bcc = solution.getBondChangeCalculator(); +``` + +### 6.1 Per-bond pairing (formed / cleaved / order change) + +```java +for (BondChange bc : bcc.getBondChangeList()) { + IBond rBond = bc.getReactantBond(); // null if bond is formed + IBond pBond = bc.getProductBond(); // null if bond is cleaved + float delta = bc.getBondChangeDelta(); + // Tag atoms/bonds in your model using rBond / pBond and map numbers from atoms +} +``` + +`BondChange` pairs **reactant-side** and **product-side** `IBond` instances (one side may be `null` for pure formation or cleavage). + +### 6.2 Maps keyed by bond (convenience) + +`BondChangeCalculator` also exposes categorised maps, for example: + +- `getBondFormedProduct()`, `getBondCleavedReactant()` +- `getBondOrderReactant()`, `getBondOrderProduct()` + +Use these if you prefer to iterate bonds by role rather than the unified list. + +### 6.3 Reaction centre and stereo + +- **Reaction-centre atoms:** `bcc.getReactionCenterSet()` +- **Stereo:** `bcc.getStereoChangeList()`, `bcc.getConformationChangeList()` +- **Fingerprints (weighted patterns):** `getFormedCleavedWFingerprint()`, `getOrderChangesWFingerprint()`, `getStereoChangesWFingerprint()`, `getReactionCenterWFingerprint()` (may throw `CDKException` in edge cases) + +### 6.4 Mapped reaction for export + +```java +IReaction mapped = bcc.getReaction(); // throws Exception in API +``` + +You can serialize with CDK **`SmilesGenerator`** using `SmiFlavor.AtomAtomMap` to emit **mapped reaction SMILES**, consistent with the `RDT` facade. + +### 6.5 Atom–atom mapping map + +- `bcc.getMappingMap()` / `bcc.getAtomAtomMappings()` — `Map` between reactant and product atoms for the chosen mapping. + +--- + +## 7. What else is useful for an application? + +These features are often valuable next to “map + bond changes”: + +1. **Reaction signature / canonical hash** (`ReactionResult.getReactionSignature()`, `getCanonicalHash()`) — stable, comparable summaries of **electron/bond-change pattern** for deduplication or search. +2. **Cross-reaction similarity** — `RDT.compare(a, b)` or fingerprint Tanimoto on `ReactionResult` (see `ReactionResult` API). +3. **Algorithm id** — `getAlgorithm()` / `MappingSolution.getAlgorithmID()` to log which strategy (**MIN**, **MAX**, **MIXTURE**, **RINGS**) won. +4. **Stereo and reaction-centre fragments** — `getReactionCenterFragmentList()`, `getReactionCentreTransformationPairs()` for mechanistic reporting or UI highlighting. +5. **Energy heuristics** — `getTotalBondBreakingEnergy()`, `getEnergyDelta()` (approximate, for ranking or display). +6. **User-provided mappings** — build `IReaction` with atom–atom maps already set, call `ReactionMechanismTool` with **`forcedMapping = false`** so RDT can **trust** existing maps when complete. +7. **Transporter / no–bond-change reactions** — `accept_no_change = true` when the chemistry is intentionally “mapping only”. +8. **Unbalanced reactions** — expect **warnings** or **skipped** mapping; fix stoichiometry or set **`forcedMapping`** knowingly. + +--- + +## 8. Troubleshooting checklist + +| Symptom | Things to check | +|--------|------------------| +| `getSelectedSolution()` is null | Unbalanced reaction + `forcedMapping` false; parse failure; empty reactants/products | +| Odd bond counts | Run **`StandardizeReaction`** path (constructor already does); ensure implicit H / aromaticity consistent with CDK expectations | +| Slow on large systems | `checkComplex` true is heavier; mapping uses internal timeouts (see codebase `CallableAtomMappingTool`, `GraphMatcher`) | +| Classpath errors | CDK version alignment; single SMSD version on the classpath | + +--- + +## 9. Primary classes to import + +| Purpose | Package / class | +|--------|-------------------| +| One-shot mapping + summary | `com.bioinceptionlabs.reactionblast.api.RDT`, `ReactionResult` | +| Full pipeline | `com.bioinceptionlabs.reactionblast.mechanism.ReactionMechanismTool`, `MappingSolution`, `BondChangeCalculator` | +| Bond-level rows | `com.bioinceptionlabs.reactionblast.mechanism.MechanismHelpers.BondChange` | +| Standardization | `com.bioinceptionlabs.reactionblast.tools.StandardizeReaction` | +| RXN V2000 read | `com.bioinceptionlabs.reactionblast.tools.ChemicalFileIO.MDLRXNV2000Reader` | + +--- + +## 10. Licence + +RDT is **LGPL-3.0**. Embedding it in another application may impose obligations (especially for distribution); check your legal requirements. + +--- + +*Generated for agent/developer onboarding. For CLI usage and benchmarks, see the repository `README.md`.* diff --git a/docs/depict-endpoint-agent.md b/docs/depict-endpoint-agent.md new file mode 100644 index 0000000..e2f44fc --- /dev/null +++ b/docs/depict-endpoint-agent.md @@ -0,0 +1,183 @@ +# Depict Endpoint Contract (Agent-Friendly) + +This document describes the HTTP contract for the `depict` endpoint implemented by `DepictController`. + +## Endpoint + +- Method: `GET` +- Path: `/depict/{style}/{fmt}` +- Required query param: `smi` + +### Path params + +- `style`: depiction style preset +- `fmt`: output format + +Supported `style` values: + +- `cow` (color on white) +- `cot` (color on transparent) +- `bow` (black on white) +- `bot` (black on transparent) +- `wob` (white on black) +- `wot` (white on transparent) +- `cob` (color-on-black adjusted) +- `nob` (neon on black) +- `not` (neon on transparent) +- `wcot` (white-friendly color on transparent) + +Supported `fmt` values (case-insensitive): + +- `svg` +- `pdf` +- `png` +- `jpg` +- `gif` + +## Required query param + +- `smi`: structure input string. + +Accepted input forms: + +- Molecule SMILES +- Reaction SMILES (contains `>`) +- Molfile text containing `V2000` +- Molfile text containing `V3000` + +Important: + +- One request = one `smi` payload. +- Multi-line processing is frontend behavior (send one request per line). + +## Optional query params + +All params are optional unless noted. + +- `sma` (default `""`): SMARTS query used for highlighting. +- `smalim` (default `100`): max SMARTS matches considered. +- `hdisp` (effective default `Smart`): hydrogen display mode. +- `suppressh` (default `true`): if false, `hdisp` is ignored and provided hydrogens are preserved. +- `alignrxnmap` (default `true`): align mapped reaction components. +- `anon` (default `false`): anonymized atom-symbol visibility mode. +- `annotate` (default `none`): annotation mode. +- `abbr` (default `reagents`): abbreviation mode. +- `bgcolor` (default `default`): background color override. +- `fgcolor` (default `default`): atom color override. +- `showtitle` (default `false`): embed title into depiction. +- `arw` (default `FORWARD`): reaction arrow type. +- `dat` (default `metals`): dative bond perception mode. +- `zoom` (default `1.3`): depiction zoom. +- `ratio` (default `1.1`): stroke ratio. +- `r` (default `0`): rotation in degrees. +- `f` (default `false`): horizontal flip. +- `w` (default `-1`): width. +- `h` (default `-1`): height. +- `svgunits` (default `mm`): unit string passed to SVG renderer. + +## Enumerated option values + +### `annotate` + +- `none` +- `number` +- `mapidx` +- `colmap` +- `rxnchg` +- `atomvalue` +- `cip` + +### `abbr` + +For reactions: + +- `on`, `true`, `yes`, `groups+agents` +- `groups` +- `reagents`, `agents` +- `off` (or omit) + +For molecules: + +- `on`, `true`, `yes`, `groups` +- `off` (or omit) + +### `hdisp` + +Accepted values map to: + +- `M` or `Minimal` -> minimal hydrogens +- `P` or `Provided` -> provided hydrogens +- `S` or `Smart` or `default` -> smart hydrogens +- `C` or `Stereo` -> stereo-relevant hydrogens +- `X` or `Explicit` -> explicit hydrogens + +### `arw` + +- `EQU` -> equilibrium +- `NGO` -> no-go +- `RET` -> retrosynthetic +- `RES` -> resonance + +### `dat` + +- `y` -> always perceive dative bonds +- `m` -> metals only +- `n` -> never + +### Boolean params + +Boolean params accept: + +- true: `t`, `true`, `on`, `1` +- false: `f`, `false`, `off`, `0` + +## Color params + +`bgcolor` and `fgcolor` support: + +- `default` (no override) +- For `bgcolor` only: `clear`, `transparent`, `null` +- Hex-like strings parsed as RGBA pairs, e.g.: + - `#RRGGBB` + - `#RRGGBBAA` + - `0xRRGGBB` + +## Response contract + +Success responses: + +- `svg` -> `Content-Type: image/svg+xml` +- `pdf` -> `Content-Type: application/pdf` +- `png|jpg|gif` -> `Content-Type: image/{fmt}` + +Headers: + +- `Access-Control-Allow-Origin: *` +- `Content-Length` is set + +## Error contract + +- Invalid SMILES -> HTTP `400`, HTML error body with title `Invalid SMILES`. +- Any other exception -> HTTP `500`, HTML error body with exception name/message. +- Unsupported `fmt` currently results in server error path (`500`) via exception handling. + +## Frontend integration notes + +- Always `encodeURIComponent` the `smi` value. +- For line-based input, split lines client-side and issue one request per non-empty/non-comment line. +- If your input line includes labels/titles, strip them before sending unless you know backend parsing supports your format. + +## Minimal examples + +### Basic SVG depiction + +`/depict/cot/svg?smi=CCO` + +### Reaction depiction with highlighting and arrow + +`/depict/bot/svg?smi=CCO%3EO%3ECC%3ECO&annotate=rxnchg&arw=RET` + +### Styled PNG with rotation and zoom + +`/depict/cow/png?smi=c1ccccc1&zoom=1.5&r=90` + diff --git a/pom.xml b/pom.xml index 532ed0c..99ba739 100644 --- a/pom.xml +++ b/pom.xml @@ -2,253 +2,226 @@ - 4.0.0 - org.openscience.cdk - cdkdepict - 1.15 - - cdkdepict-lib - cdkdepict-webapp - - pom - cdkdepict - SMILES depiction WebApp - http://github.com/cdk/cdkdepict - - UTF-8 - 2.12 - ${project.parent.version} - 5.14.3 - 2.2 - 6.2.17 - 3.5.13 - - - - John Mayfield - http://www.github.com/johnmay/ - GMT - - - - - GNU LGPL 2.1 or later - http://www.gnu.org/licenses/lgpl.html - repo - - - - https://github.com/cdk/cdk - scm:git:git://github.com/cdk/cdk.git - scm:git:git@github.com:cdk/cdk.git - - - - https://github.com/cdk/depict/issues - GitHub - - - - central - https://central.sonatype.com/repository/maven-snapshots/ - - - central - https://central.sonatype.com/ - default - - - - cdk.github.com - https://cdk.github.io/cdk/ - - - - - central-snapshot - https://central.sonatype.com/repository/maven-snapshots/ - - false - - - true - - - - + 4.0.0 + org.openscience.cdk + cdkdepict + 1.14 + + cdkdepict-lib + cdkdepict-webapp + + pom + cdkdepict + SMILES depiction WebApp + http://github.com/cdk/cdkdepict + + UTF-8 + 17 + 2.12 + 4.0.0 + ${project.parent.version} + 5.11.4 + 2.2 + 6.2.1 + 3.4.1 + + + + John Mayfield + http://www.github.com/johnmay/ + GMT + + + + + GNU LGPL 2.1 or later + http://www.gnu.org/licenses/lgpl.html + repo + + + + https://github.com/cdk/cdk + scm:git:git://github.com/cdk/cdk.git + scm:git:git@github.com:cdk/cdk.git + + + + https://github.com/cdk/depict/issues + GitHub + + + + ossrh + https://s01.oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + cdk.github.com + http://cdk.github.io/cdk/ + + + + + OSSRH + https://s01.oss.sonatype.org/content/repositories/snapshots + + false + + + true + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + ${java.version} + + + + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + jar + test + + + org.hamcrest + hamcrest-library + ${hamcrest.version} + test + + + org.springframework + spring-webmvc + ${spring-webmvc.version} + + + org.openscience.cdk + cdk-atomtype + ${cdk.version} + + + org.openscience.cdk + cdk-standard + ${cdk.version} + + + org.openscience.cdk + cdk-io + ${cdk.version} + + + org.openscience.cdk + cdk-ioformats + ${cdk.version} + + + org.openscience.cdk + cdk-isomorphism + ${cdk.version} + + + org.openscience.cdk + cdk-smiles + ${cdk.version} + + + org.openscience.cdk + cdk-silent + ${cdk.version} + + + org.openscience.cdk + cdk-interfaces + ${cdk.version} + + + org.openscience.cdk + cdk-ctab + ${cdk.version} + + + + + + ossrh + - - org.apache.maven.plugins - maven-compiler-plugin - 3.2 + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.13 + true + + ossrh + https://s01.oss.sonatype.org/ + true + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + - 17 - 17 + gpg + + --pinentry-mode + loopback + + ${gpg.passphrase} - + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.3.1 + + + attach-javadocs + + jar + + + + - - - - - org.junit.jupiter - junit-jupiter - ${junit.version} - jar - test - - - org.hamcrest - hamcrest-library - ${hamcrest.version} - test - - - org.springframework - spring-webmvc - ${spring-webmvc.version} - - - org.openscience.cdk - cdk-atomtype - ${cdk.version} - - - org.openscience.cdk - cdk-standard - ${cdk.version} - - - org.openscience.cdk - cdk-io - ${cdk.version} - - - org.openscience.cdk - cdk-ioformats - ${cdk.version} - - - org.openscience.cdk - cdk-isomorphism - ${cdk.version} - - - org.openscience.cdk - cdk-smiles - ${cdk.version} - - - org.openscience.cdk - cdk-silent - ${cdk.version} - - - org.openscience.cdk - cdk-interfaces - ${cdk.version} - - - org.openscience.cdk - cdk-ctab - ${cdk.version} - - - - - - javaee - - 5.3.39 - 2.7.18 - - - - - org.apache.maven.plugins - maven-war-plugin - - src/main/webapp/WEB-INF/web-javaee.xml - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.2 - - 8 - 8 - - - - - - - central-deploy - - - - org.sonatype.central - central-publishing-maven-plugin - 0.9.0 - true - - central - true - published - - - - org.apache.maven.plugins - maven-gpg-plugin - 3.2.8 - - - sign-artifacts - verify - - sign - - - gpg - - --pinentry-mode - loopback - - ${gpg.passphrase} - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.3.1 - - - attach-sources - - jar - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.3.1 - - - attach-javadocs - - jar - - - - - - - - + + +