From efa7a32c3c3c99966e287f4246e1ff0dd1e2f3e5 Mon Sep 17 00:00:00 2001 From: Brandon Novy <142041993+Brandon-Cole@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:59:00 -0400 Subject: [PATCH] MolDraw2D: configurable legend position and vertical side legends (Issue #9023) (#9183) * Configurable legend position (Top/Left/Right/Bottom) and vertical text (GitHub #9023) - Add LegendPosition enum and legendPosition, legendVerticalText to MolDrawOptions - Support legend at Top, Left, Right, Bottom; vertical text for Left/Right - Python: MolDrawOptions.legendPosition, .legendVerticalText; LegendPosition enum - Python: MolToSVG() wrapper with legend/drawOptions; doc updates for MolToImage - JSON: legendPosition (string), legendVerticalText (bool) in draw options - C++ and Python tests; release note and Cartridge.md docs * MolDraw2D: legend gutter for horizontal side legends; vertical side height fit - Reserve horizontal gap between molecule and left/right horizontal legends (scale mol to molWidth-gutter, align toward legend strip). - Position horizontal side legend by measured text width from partition edge. - Vertical side legends: iterative scale so n*max_h+(n-1)*gap fits panel. - Catch: long vertical side legend section. * Update legend-position tests and review-driven cleanup Use enum/default wording for legendPosition docs, move the lightweight Python test to Wrap, add regex-based placement checks (including horizontal side and vertical stacking), and refactor extractLegend helpers per style guidance. * Fix MolDraw2D legend edge cases * MolDraw2D: review follow-up (legend tests, bounds, DRY Top/Bottom) * Update no-FT legend test coords * Address PR review: document constants, remove release-note text, and simplify extra-padding logic --- Code/GraphMol/MolDraw2D/DrawMol.cpp | 500 ++++++++++++++---- Code/GraphMol/MolDraw2D/DrawMol.h | 5 +- Code/GraphMol/MolDraw2D/MolDraw2DHelpers.h | 7 + .../MolDraw2D/MolDrawOptionsJSONParsers.cpp | 22 + Code/GraphMol/MolDraw2D/Wrap/rdMolDraw2D.cpp | 15 + Code/GraphMol/MolDraw2D/Wrap/testMolDraw2D.py | 9 + Code/GraphMol/MolDraw2D/catch_tests.cpp | 182 ++++++- Code/GraphMol/MolDraw2D/test1.cpp | 24 +- Docs/Book/Cartridge.md | 2 +- rdkit/Chem/Draw/__init__.py | 28 +- 10 files changed, 680 insertions(+), 114 deletions(-) diff --git a/Code/GraphMol/MolDraw2D/DrawMol.cpp b/Code/GraphMol/MolDraw2D/DrawMol.cpp index 5f932a4d7..dffb52625 100755 --- a/Code/GraphMol/MolDraw2D/DrawMol.cpp +++ b/Code/GraphMol/MolDraw2D/DrawMol.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -1115,8 +1116,24 @@ void DrawMol::calculateScale() { partitionForLegend(); if (xRange_ > 1e-4 || yRange_ > 1e-4) { + double widthForScale = molWidth_ > 0 ? double(molWidth_) : double(drawWidth_); + const bool sideLegendHoriz = + !legend_.empty() && + (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Left || + drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Right) && + !drawOptions_.legendVerticalText; + if (sideLegendHoriz && molWidth_ > 0) { + // Keep a minimum absolute side gap of 8 px so labels do not crowd the + // horizontal side legend on small canvases. + const double g = + std::max(8.0, marginPadding_ * static_cast(drawWidth_)); + // Require at least ~24 px of drawable molecule width after the gap. + if (double(molWidth_) > g + 24.0) { + widthForScale = double(molWidth_) - g; + } + } newScale = - std::min(double(drawWidth_) / xRange_, double(molHeight_) / yRange_); + std::min(widthForScale / xRange_, double(molHeight_) / yRange_); double fix_scale = newScale; // after all that, use the fixed scale unless it's too big, in which case // scale the drawing down to fit. @@ -1125,7 +1142,7 @@ void DrawMol::calculateScale() { fix_scale = drawOptions_.fixedBondLength; } if (drawOptions_.fixedScale > 0.0) { - fix_scale = double(drawWidth_) * drawOptions_.fixedScale; + fix_scale = widthForScale * drawOptions_.fixedScale; } if (newScale > fix_scale) { newScale = fix_scale; @@ -1707,20 +1724,250 @@ void DrawMol::calcMeanBondLength() { // **************************************************************************** void DrawMol::partitionForLegend() { + legendWidth_ = 0; + molWidth_ = drawWidth_; if (legend_.empty()) { molHeight_ = drawHeight_; legendHeight_ = 0; - } else { - if (!flexiCanvasY_) { - legendHeight_ = int(drawOptions_.legendFraction * float(drawHeight_)); - molHeight_ = drawHeight_ - legendHeight_; - } else { + return; + } + switch (drawOptions_.legendPosition) { + case MolDrawOptions::LegendPosition::Bottom: + case MolDrawOptions::LegendPosition::Top: + if (!flexiCanvasY_) { + legendHeight_ = int(drawOptions_.legendFraction * float(drawHeight_)); + molHeight_ = drawHeight_ - legendHeight_; + } else { + molHeight_ = drawHeight_; + } + break; + case MolDrawOptions::LegendPosition::Left: + case MolDrawOptions::LegendPosition::Right: + legendWidth_ = int(drawOptions_.legendFraction * float(drawWidth_)); + if (legendWidth_ <= 0 && drawWidth_ > 0) { + legendWidth_ = 1; + } + molWidth_ = drawWidth_ - legendWidth_; + if (molWidth_ < 1) { + molWidth_ = drawWidth_; + legendWidth_ = 0; + } molHeight_ = drawHeight_; - // the legendHeight_ isn't needed for the flexiCanvas + legendHeight_ = 0; + break; + } +} + +namespace { + +void calc_legend_dims(const std::vector &legend_bits, + double relFontScale, const DrawColour &legendColour, + DrawText &textDrawer, double &total_width, + double &total_height) { + total_width = total_height = 0.0; + for (const auto &bit : legend_bits) { + double height, width; + DrawAnnotation da(bit, TextAlignType::MIDDLE, "legend", relFontScale, + Point2D(0.0, 0.0), legendColour, textDrawer); + da.getDimensions(width, height); + total_height += height; + total_width = std::max(total_width, width); + } +} + +double calc_line_gap(const DrawText &textDrawer, bool vertText, + double relFontScale) { + const double font_px = textDrawer.fontSize() * relFontScale; + if (vertText) { + // Vertical legend text needs more inter-glyph breathing room. + // 0.35*font_px was tuned to avoid glyph overlap across common fonts. + return std::max(1.0, font_px * 0.35); + } + // Horizontal legend lines can be tighter; keep a 2 px minimum so short + // legends remain readable on small canvases. + return std::max(2.0, font_px * 0.15); +} + +std::vector split_legend_bits(const std::string &legend, + bool vertText) { + std::vector legend_bits; + if (vertText) { + legend_bits.reserve(legend.size()); + for (char c : legend) { + if (c != '\n') { + legend_bits.emplace_back(1, c); + } + } + return legend_bits; + } + boost::split(legend_bits, legend, boost::is_any_of("\n")); + legend_bits.erase(std::remove_if(legend_bits.begin(), legend_bits.end(), + [](const std::string &s) { + return s.empty(); + }), + legend_bits.end()); + return legend_bits; +} + +void place_bottom_legend(const std::vector &legend_bits, + double relFontScale, const DrawColour &legendColour, + DrawText &textDrawer, double baseX, double baseY, + double drawWidth, double drawHeight, + std::vector> &legends) { + Point2D loc(baseX + drawWidth / 2.0, baseY + drawHeight); + for (const auto &bit : legend_bits) { + legends.emplace_back(new DrawAnnotation(bit, TextAlignType::MIDDLE, "legend", + relFontScale, loc, legendColour, + textDrawer)); + } + double xmin, xmax, ymin, ymax; + double lastAbove = 0.0; + for (int i = static_cast(legends.size()) - 1; i >= 0; --i) { + xmin = ymin = std::numeric_limits::max(); + xmax = ymax = std::numeric_limits::lowest(); + legends[i]->findExtremes(xmin, xmax, ymin, ymax); + double thisBelow = legends[i]->pos_.y - ymax; + double thisAbove = legends[i]->pos_.y - ymin; + if (i == static_cast(legends.size()) - 1) { + legends[i]->pos_.y += thisBelow; + } else { + legends[i]->pos_.y = legends[i + 1]->pos_.y + thisBelow - lastAbove; + } + lastAbove = thisAbove; + } +} + +void place_top_legend(const std::vector &legend_bits, + double relFontScale, const DrawColour &legendColour, + DrawText &textDrawer, double baseX, double baseY, + double drawWidth, double drawHeight, int legendHeight, + std::vector> &legends) { + const double gap = calc_line_gap(textDrawer, false, relFontScale); + double total_h = 0.0; + for (size_t i = 0; i < legend_bits.size(); ++i) { + double height, width; + DrawAnnotation da(legend_bits[i], TextAlignType::MIDDLE, "legend", + relFontScale, Point2D(0.0, 0.0), legendColour, textDrawer); + da.getDimensions(width, height); + total_h += height; + if (i + 1 < legend_bits.size()) { + total_h += gap; + } + } + const double band_h = legendHeight > 0 ? double(legendHeight) : drawHeight; + double y = baseY + (band_h - total_h) / 2.0; + for (size_t i = 0; i < legend_bits.size(); ++i) { + double height, width; + DrawAnnotation da(legend_bits[i], TextAlignType::MIDDLE, "legend", + relFontScale, Point2D(0.0, 0.0), legendColour, textDrawer); + da.getDimensions(width, height); + y += height / 2.0; + Point2D loc(baseX + drawWidth / 2.0, y); + legends.emplace_back(new DrawAnnotation(legend_bits[i], TextAlignType::MIDDLE, + "legend", relFontScale, loc, + legendColour, textDrawer)); + y += height / 2.0; + if (i + 1 < legend_bits.size()) { + y += gap; } } } +void place_side_legend(const std::vector &legend_bits, + double relFontScale, const DrawColour &legendColour, + DrawText &textDrawer, bool vertText, bool is_left, + double baseX, double baseY, int legendWidth, int molWidth, + double drawWidth, double drawHeight, double marginPadding, + std::vector> &legends) { + if (legend_bits.empty()) { + return; + } + double stripCentreX = 0.0; + if (vertText) { + stripCentreX = is_left ? baseX + legendWidth / 2.0 + : baseX + drawWidth - legendWidth / 2.0; + } + const double stripCentreY = baseY + drawHeight / 2.0; + const double gap = calc_line_gap(textDrawer, vertText, relFontScale); + if (!vertText) { + double maxLineW = 0.0; + for (const auto &bit : legend_bits) { + double h = 0.0, w = 0.0; + DrawAnnotation da(bit, TextAlignType::MIDDLE, "legend", relFontScale, + Point2D(0.0, 0.0), legendColour, textDrawer); + da.getDimensions(w, h); + maxLineW = std::max(maxLineW, w); + } + const double pad = std::max(8.0, marginPadding * drawWidth); + if (is_left) { + const double molPanelLeft = baseX + static_cast(legendWidth); + stripCentreX = molPanelLeft - pad - maxLineW / 2.0; + stripCentreX = std::max(stripCentreX, baseX + maxLineW / 2.0 + 2.0); + } else { + const double molPanelRight = baseX + static_cast(molWidth); + stripCentreX = molPanelRight + pad + maxLineW / 2.0; + stripCentreX = + std::min(stripCentreX, baseX + drawWidth - maxLineW / 2.0 - 2.0); + } + } + if (vertText) { + double max_h = 0.0; + for (const auto &bit : legend_bits) { + double height, width; + DrawAnnotation da(bit, TextAlignType::MIDDLE, "legend", relFontScale, + Point2D(0.0, 0.0), legendColour, textDrawer); + da.getDimensions(width, height); + max_h = std::max(max_h, height); + } + const double total_h = + legend_bits.size() * max_h + (legend_bits.size() - 1) * gap; + double y = stripCentreY - total_h / 2.0; + for (size_t i = 0; i < legend_bits.size(); ++i) { + y += max_h / 2.0; + Point2D loc(stripCentreX, y); + legends.emplace_back(new DrawAnnotation(legend_bits[i], TextAlignType::MIDDLE, + "legend", relFontScale, loc, + legendColour, textDrawer)); + y += max_h / 2.0; + if (i + 1 < legend_bits.size()) { + y += gap; + } + } + } else { + double total_h = 0.0; + for (size_t i = 0; i < legend_bits.size(); ++i) { + double height, width; + DrawAnnotation da(legend_bits[i], TextAlignType::MIDDLE, "legend", + relFontScale, Point2D(0.0, 0.0), legendColour, + textDrawer); + da.getDimensions(width, height); + total_h += height; + if (i + 1 < legend_bits.size()) { + total_h += gap; + } + } + double y = stripCentreY - total_h / 2.0; + for (size_t i = 0; i < legend_bits.size(); ++i) { + double height, width; + DrawAnnotation da(legend_bits[i], TextAlignType::MIDDLE, "legend", + relFontScale, Point2D(0.0, 0.0), legendColour, + textDrawer); + da.getDimensions(width, height); + y += height / 2.0; + Point2D loc(stripCentreX, y); + legends.emplace_back(new DrawAnnotation(legend_bits[i], TextAlignType::MIDDLE, + "legend", relFontScale, loc, + legendColour, textDrawer)); + y += height / 2.0; + if (i + 1 < legend_bits.size()) { + y += gap; + } + } + } +} + +} // namespace + // **************************************************************************** // This must be called after calculateScale() because it needs the final // font size to work out the legend font size which is given in @@ -1730,97 +1977,131 @@ void DrawMol::extractLegend() { if (legend_.empty()) { return; } - auto calc_legend_height = [&](const std::vector &legend_bits, - double relFontScale, double &total_width, - double &total_height) { - total_width = total_height = 0; - for (auto &bit : legend_bits) { - double height, width; - DrawAnnotation da(bit, TextAlignType::MIDDLE, "legend", relFontScale, - Point2D(0.0, 0.0), drawOptions_.legendColour, - textDrawer_); - da.getDimensions(width, height); - total_height += height; - total_width = std::max(total_width, width); - } - }; - - std::vector legend_bits; - // split any strings on newlines - std::string next_piece; - for (auto c : legend_) { - if (c == '\n') { - if (!next_piece.empty()) { - legend_bits.push_back(next_piece); - } - next_piece = ""; - } else { - next_piece += c; - } - } - if (!next_piece.empty()) { - legend_bits.push_back(next_piece); + const bool vertText = + (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Left || + drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Right) && + drawOptions_.legendVerticalText; + std::vector legend_bits = split_legend_bits(legend_, vertText); + if (legend_bits.empty()) { + return; } - // work out a font scale that allows the pieces to fit, remembering there's - // padding round the picture. double fsize = textDrawer_.fontSize(); double relFontScale = drawOptions_.legendFontSize / fsize; double total_width, total_height; - calc_legend_height(legend_bits, relFontScale, total_width, total_height); - if (total_width >= drawWidth_) { - if (!flexiCanvasX_) { - relFontScale *= double(drawWidth_) / total_width; - calc_legend_height(legend_bits, relFontScale, total_width, total_height); - } else { - width_ = total_width * (1 + 2 * marginPadding_); - drawWidth_ = total_width; - } - } + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); - if (!flexiCanvasY_) { - auto adjLegHt = drawHeight_ * drawOptions_.legendFraction; - // subtract off space for the padding. - if (total_height > adjLegHt) { - relFontScale *= double(adjLegHt) / total_height; - calc_legend_height(legend_bits, relFontScale, total_width, total_height); - } + double maxWidth = double(drawWidth_); + double maxHeight = double(drawHeight_); + if (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Left || + drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Right) { + maxWidth = legendWidth_ > 0 ? double(legendWidth_) : double(drawWidth_); } else { - // a small gap between the legend and the picture looks better, - // and make it at least 2 pixels. - double extra_padding = total_height * marginPadding_; - extra_padding = extra_padding < 2.0 ? 2.0 : extra_padding; - legendHeight_ = total_height + extra_padding; - drawHeight_ += legendHeight_; - height_ += legendHeight_; + maxHeight = legendHeight_ > 0 ? double(legendHeight_) : double(drawHeight_); } - Point2D loc(drawWidth_ / 2 + xOffset_ + width_ * marginPadding_, - marginPadding_ * height_ + drawHeight_ + yOffset_); - for (auto bit : legend_bits) { - DrawAnnotation *da = - new DrawAnnotation(bit, TextAlignType::MIDDLE, "legend", relFontScale, - loc, drawOptions_.legendColour, textDrawer_); - legends_.emplace_back(da); + // For horizontal text on the sides (Left/Right, legendVerticalText false), + // do not scale down by strip width so the font size matches Top/Bottom. + const bool sideHorizontal = + (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Left || + drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Right) && + !vertText; + if (total_width > maxWidth && maxWidth > 0 && !flexiCanvasX_ && !sideHorizontal) { + relFontScale *= maxWidth / total_width; + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); } - // The letters have different amounts above and below the centre, - // which matters when placing them vertically. - // Draw them from the bottom up. - double xmin, xmax, ymin, ymax; - xmin = ymin = std::numeric_limits::max(); - xmax = ymax = std::numeric_limits::lowest(); - legends_.back()->findExtremes(xmin, xmax, ymin, ymax); - double lastBelow = legends_.back()->pos_.y - ymax; - double lastAbove = legends_.back()->pos_.y - ymin; - legends_.back()->pos_.y += lastBelow; - for (int i = legends_.size() - 2; i >= 0; --i) { - xmin = ymin = std::numeric_limits::max(); - xmax = ymax = std::numeric_limits::lowest(); - legends_[i]->findExtremes(xmin, xmax, ymin, ymax); - double thisBelow = legends_[i]->pos_.y - ymax; - double thisAbove = legends_[i]->pos_.y - ymin; - legends_[i]->pos_.y = legends_[i + 1]->pos_.y + thisBelow - lastAbove; - lastAbove = thisAbove; + if (total_height > maxHeight && maxHeight > 0) { + if (!flexiCanvasY_) { + relFontScale *= maxHeight / total_height; + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); + } else if (drawOptions_.legendPosition == + MolDrawOptions::LegendPosition::Bottom || + drawOptions_.legendPosition == + MolDrawOptions::LegendPosition::Top) { + double extra_padding = total_height * marginPadding_; + extra_padding = std::max(extra_padding, 2.0); + legendHeight_ = int(total_height + extra_padding); + drawHeight_ += legendHeight_; + height_ += legendHeight_; + if (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Top) { + molHeight_ = drawHeight_ - legendHeight_; + } + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); + } + } + + // Horizontal text on the sides should match Top/Bottom size: never shrink + // below the default legend font scale (canvas-sized scaling made it tiny). + const double initialFontScale = drawOptions_.legendFontSize / fsize; + if (sideHorizontal && relFontScale < initialFontScale) { + relFontScale = initialFontScale; + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); + } + + // Vertical side legends use uniform line height + gaps; scale so the stack + // fits the molecule panel (sum of per-glyph heights can underestimate span). + if (vertText && + (drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Left || + drawOptions_.legendPosition == MolDrawOptions::LegendPosition::Right) && + !legend_bits.empty() && drawHeight_ > 0) { + const double marginY = + std::max(4.0, marginPadding_ * static_cast(height_)); + const double avail = std::max(1.0, drawHeight_ - 2.0 * marginY); + for (int iter = 0; iter < 8; ++iter) { + double max_h = 0.0; + for (const auto &bit : legend_bits) { + double h = 0.0, w = 0.0; + DrawAnnotation da(bit, TextAlignType::MIDDLE, "legend", relFontScale, + Point2D(0.0, 0.0), drawOptions_.legendColour, + textDrawer_); + da.getDimensions(w, h); + max_h = std::max(max_h, h); + } + const double gap = calc_line_gap(textDrawer_, vertText, relFontScale); + const size_t n = legend_bits.size(); + const double span = + static_cast(n) * max_h + + (n > 1 ? static_cast(n - 1) * gap : 0.0); + if (span <= avail || span <= 0.0) { + break; + } + relFontScale *= avail / span; + calc_legend_dims(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, total_width, total_height); + } + } + + const double baseX = xOffset_ + width_ * marginPadding_; + const double baseY = marginPadding_ * height_ + yOffset_; + + switch (drawOptions_.legendPosition) { + case MolDrawOptions::LegendPosition::Bottom: + place_bottom_legend(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, baseX, baseY, drawWidth_, drawHeight_, + legends_); + break; + case MolDrawOptions::LegendPosition::Top: + place_top_legend(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, baseX, baseY, drawWidth_, drawHeight_, + legendHeight_, legends_); + break; + case MolDrawOptions::LegendPosition::Left: + place_side_legend(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, vertText, true, baseX, baseY, legendWidth_, + molWidth_, drawWidth_, drawHeight_, marginPadding_, + legends_); + break; + case MolDrawOptions::LegendPosition::Right: + place_side_legend(legend_bits, relFontScale, drawOptions_.legendColour, + textDrawer_, vertText, false, baseX, baseY, legendWidth_, + molWidth_, drawWidth_, drawHeight_, marginPadding_, + legends_); + break; } } @@ -2802,10 +3083,43 @@ void DrawMol::getDrawTransformers(Point2D &trans, Point2D &scale, trans = Point2D(-xMin_, -yMin_); scale = Point2D(scale_, scale_); Point2D scaledRanges(scale_ * xRange_, scale_ * yRange_); - toCentre = Point2D( - (drawWidth_ - scaledRanges.x) / 2.0 + xOffset_ + width_ * marginPadding_, - (molHeight_ - scaledRanges.y) / 2.0 + yOffset_ + - height_ * marginPadding_); + double molW = (molWidth_ > 0) ? double(molWidth_) : double(drawWidth_); + double xCentre = + (molW - scaledRanges.x) / 2.0 + xOffset_ + width_ * marginPadding_; + double yCentre = + (molHeight_ - scaledRanges.y) / 2.0 + yOffset_ + height_ * marginPadding_; + const double basePanelX = xOffset_ + width_ * marginPadding_; + switch (drawOptions_.legendPosition) { + case MolDrawOptions::LegendPosition::Bottom: + break; + case MolDrawOptions::LegendPosition::Top: + yCentre += legendHeight_; + break; + case MolDrawOptions::LegendPosition::Left: + if (!legend_.empty() && !drawOptions_.legendVerticalText && + molWidth_ > 0) { + // Same minimum side gap used in initDrawMolecule() so scaling and final + // placement stay consistent for horizontal side legends. + const double g = + std::max(8.0, marginPadding_ * static_cast(drawWidth_)); + // Drawn x spans [toCentre.x, toCentre.x + scaledRanges.x]; leave g past + // the legend strip before the molecule. + xCentre = basePanelX + double(legendWidth_) + g; + } else { + xCentre += legendWidth_; + } + break; + case MolDrawOptions::LegendPosition::Right: + if (!legend_.empty() && !drawOptions_.legendVerticalText && + molWidth_ > 0) { + // Same minimum side gap used on the left side for symmetry. + const double g = + std::max(8.0, marginPadding_ * static_cast(drawWidth_)); + xCentre = basePanelX + double(molWidth_) - g - scaledRanges.x; + } + break; + } + toCentre = Point2D(xCentre, yCentre); } // **************************************************************************** diff --git a/Code/GraphMol/MolDraw2D/DrawMol.h b/Code/GraphMol/MolDraw2D/DrawMol.h index 555514057..ee2ad0400 100755 --- a/Code/GraphMol/MolDraw2D/DrawMol.h +++ b/Code/GraphMol/MolDraw2D/DrawMol.h @@ -306,8 +306,11 @@ class DrawMol { double meanBondLength_ = 0.0; // if there's a legend, we reserve a bit for it. molHeight_ is the // bit for drawing the molecule, legendHeight_ the bit under that - // for the legend. In pixels. + // for the legend. For Left/Right, legendWidth_ is the side strip. + // molWidth_ is the width available for the molecule (drawWidth_ for + // Bottom/Top, drawWidth_ - legendWidth_ for Left/Right). In pixels. int molHeight_, legendHeight_ = 0; + int molWidth_ = 0, legendWidth_ = 0; bool drawingInitialised_ = false; // when drawing the atoms and bonds in an SVG, they are given a class // via MolDraw2D's activeAtmIdx[12]_ and activeBndIdx. We don't always want diff --git a/Code/GraphMol/MolDraw2D/MolDraw2DHelpers.h b/Code/GraphMol/MolDraw2D/MolDraw2DHelpers.h index bf302f208..077bab9e1 100644 --- a/Code/GraphMol/MolDraw2D/MolDraw2DHelpers.h +++ b/Code/GraphMol/MolDraw2D/MolDraw2DHelpers.h @@ -215,6 +215,13 @@ struct RDKIT_MOLDRAW2D_EXPORT MolDrawOptions { // BuiltinRobotoRegular. DrawColour legendColour{0, 0, 0}; // color to be used for the legend (if present) + //! Legend position relative to the molecule (only Bottom supported in + //! drawMolecules grid; all four work for single-molecule drawing). + enum class LegendPosition { Bottom, Top, Left, Right }; + LegendPosition legendPosition = LegendPosition::Bottom; + //! When legend is Left or Right, draw text vertically (one character per + //! line). Ignored for Top/Bottom. + bool legendVerticalText = true; double multipleBondOffset = 0.15; // offset for the extra lines // in a multiple bond as a fraction of // mean bond length diff --git a/Code/GraphMol/MolDraw2D/MolDrawOptionsJSONParsers.cpp b/Code/GraphMol/MolDraw2D/MolDrawOptionsJSONParsers.cpp index 52849383b..5cd2c6758 100644 --- a/Code/GraphMol/MolDraw2D/MolDrawOptionsJSONParsers.cpp +++ b/Code/GraphMol/MolDraw2D/MolDrawOptionsJSONParsers.cpp @@ -104,6 +104,26 @@ void get_highlight_style_option(const boost::property_tree::ptree &pt, } } +void get_legend_position_option(const boost::property_tree::ptree &pt, + const char *pnm, + MolDrawOptions::LegendPosition &pos) { + PRECONDITION(pnm && strlen(pnm), "bad property name"); + if (pt.find(pnm) == pt.not_found()) { + return; + } + const auto &node = pt.get_child(pnm); + auto str = node.get_value(); + if (str == "Top") { + pos = MolDrawOptions::LegendPosition::Top; + } else if (str == "Left") { + pos = MolDrawOptions::LegendPosition::Left; + } else if (str == "Right") { + pos = MolDrawOptions::LegendPosition::Right; + } else { + pos = MolDrawOptions::LegendPosition::Bottom; + } +} + void updateMolDrawOptionsFromJSON(MolDrawOptions &opts, const std::string &json) { if (json.empty()) { @@ -188,6 +208,8 @@ void updateMolDrawOptionsFromJSON(MolDrawOptions &opts, } get_highlight_style_option(pt, "multiColourHighlightStyle", opts.multiColourHighlightStyle); + get_legend_position_option(pt, "legendPosition", opts.legendPosition); + PT_OPT_GET(legendVerticalText); const auto drawingExtentsIncludeIt = pt.find("drawingExtentsInclude"); if (drawingExtentsIncludeIt != pt.not_found()) { bool haveDrawElementFlags = false; diff --git a/Code/GraphMol/MolDraw2D/Wrap/rdMolDraw2D.cpp b/Code/GraphMol/MolDraw2D/Wrap/rdMolDraw2D.cpp index bce36e71e..377505a94 100644 --- a/Code/GraphMol/MolDraw2D/Wrap/rdMolDraw2D.cpp +++ b/Code/GraphMol/MolDraw2D/Wrap/rdMolDraw2D.cpp @@ -788,6 +788,13 @@ BOOST_PYTHON_MODULE(rdMolDraw2D) { .value("Lasso", RDKit::MultiColourHighlightStyle::LASSO) .export_values(); + python::enum_("LegendPosition") + .value("Bottom", RDKit::MolDrawOptions::LegendPosition::Bottom) + .value("Top", RDKit::MolDrawOptions::LegendPosition::Top) + .value("Left", RDKit::MolDrawOptions::LegendPosition::Left) + .value("Right", RDKit::MolDrawOptions::LegendPosition::Right) + .export_values(); + { python::enum_ drawElementEnum( "DrawElement"); @@ -919,6 +926,14 @@ BOOST_PYTHON_MODULE(rdMolDraw2D) { .def_readwrite( "legendFraction", &RDKit::MolDrawOptions::legendFraction, "fraction of the draw panel to be used for the legend if present") + .def_readwrite( + "legendPosition", &RDKit::MolDrawOptions::legendPosition, + "legend position enum. Default=Bottom. " + "Values: LegendPosition.Bottom, LegendPosition.Top, " + "LegendPosition.Left, LegendPosition.Right.") + .def_readwrite( + "legendVerticalText", &RDKit::MolDrawOptions::legendVerticalText, + "when legend is Left or Right, draw text vertically (one char per line)") .def_readwrite("maxFontSize", &RDKit::MolDrawOptions::maxFontSize, "maximum font size in pixels. default=40, -1 means no" " maximum.") diff --git a/Code/GraphMol/MolDraw2D/Wrap/testMolDraw2D.py b/Code/GraphMol/MolDraw2D/Wrap/testMolDraw2D.py index 8f82941e5..dcaba58b7 100644 --- a/Code/GraphMol/MolDraw2D/Wrap/testMolDraw2D.py +++ b/Code/GraphMol/MolDraw2D/Wrap/testMolDraw2D.py @@ -989,6 +989,15 @@ M END highlightCoords = extractCoords(text) self.assertTrue(checkCoords(referenceCoords, highlightCoords)) + def testLegendPosition(self): + mol = Chem.MolFromSmiles('CCO') + self.assertIsNotNone(mol) + opts = rdMolDraw2D.MolDrawOptions() + self.assertEqual(opts.legendPosition, rdMolDraw2D.LegendPosition.Bottom) + opts.legendPosition = rdMolDraw2D.LegendPosition.Top + svg = Draw.MolToSVG(mol, legend='Ethanol', drawOptions=opts) + self.assertIn("class='legend'", svg) + if __name__ == "__main__": unittest.main() diff --git a/Code/GraphMol/MolDraw2D/catch_tests.cpp b/Code/GraphMol/MolDraw2D/catch_tests.cpp index 4439ec433..67a5e06a5 100644 --- a/Code/GraphMol/MolDraw2D/catch_tests.cpp +++ b/Code/GraphMol/MolDraw2D/catch_tests.cpp @@ -74,9 +74,9 @@ const std::map SVG_HASHES = { {"testDativeBonds_2c.svg", 1745138239U}, {"testDativeBonds_2d.svg", 3279423301U}, {"testZeroOrderBonds_1.svg", 3733430366U}, - {"testFoundations_1.svg", 2350247048U}, - {"testFoundations_2.svg", 15997352U}, - {"testTest_1.svg", 15997352U}, + {"testFoundations_1.svg", 2283802316U}, + {"testFoundations_2.svg", 2468031318U}, + {"testTest_1.svg", 2468031318U}, {"testKekulizationProblems_1.svg", 2284161107U}, {"testAtomBondIndices_1.svg", 2702803018U}, {"testAtomBondIndices_2.svg", 1564350363U}, @@ -219,9 +219,9 @@ const std::map SVG_HASHES = { {"testGithub4764.sz3.svg", 2712214121U}, {"testDrawArc1.svg", 3279637525U}, {"testMetalWedges.svg", 2896721486U}, - {"testVariableLegend_1.svg", 1817838365U}, - {"testVariableLegend_2.svg", 1038247753U}, - {"testVariableLegend_3.svg", 2073034956U}, + {"testVariableLegend_1.svg", 1208675629U}, + {"testVariableLegend_2.svg", 799897710U}, + {"testVariableLegend_3.svg", 2599269417U}, {"testGithub_5061.svg", 2050932431U}, {"testGithub_5185.svg", 3800073130U}, {"testGithub_5269_1.svg", 4160868253U}, @@ -4855,6 +4855,176 @@ TEST_CASE("vary proportion of panel for legend", "[drawing]") { } } +TEST_CASE("legend position Top Left Right and vertical text", "[drawing]") { + auto m1 = "CCO"_smiles; + REQUIRE(m1); + const std::string legend("Ethanol"); + auto get_legend_xy = [](const std::string &text, double &x, double &y) { + std::smatch match; + std::regex textRgx( + " 1); + for (size_t i = 1; i < coords.size(); ++i) { + CHECK(coords[i].second > coords[i - 1].second); + } + std::ofstream outs("testLegendPosition_left_vertical.svg"); + outs << text; + outs.flush(); + } + SECTION("Left horizontal") { + MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE); + drawer.drawOptions().legendPosition = + MolDrawOptions::LegendPosition::Left; + drawer.drawOptions().legendVerticalText = false; + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, legend); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + CHECK(text.find("class='legend'") != std::string::npos); + CHECK(get_legend_xy(text, left_x, left_y)); + CHECK(left_x < 80.0); + std::ofstream outs("testLegendPosition_left_horizontal.svg"); + outs << text; + outs.flush(); + } + SECTION("Right horizontal") { + MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE); + drawer.drawOptions().legendPosition = + MolDrawOptions::LegendPosition::Right; + drawer.drawOptions().legendVerticalText = false; + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, legend); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + CHECK(text.find("class='legend'") != std::string::npos); + CHECK(get_legend_xy(text, right_x, right_y)); + CHECK(right_x > 100.0); + std::ofstream outs("testLegendPosition_right_horizontal.svg"); + outs << text; + outs.flush(); + } + SECTION("Bottom unchanged default") { + MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE); + CHECK(drawer.drawOptions().legendPosition == + MolDrawOptions::LegendPosition::Bottom); + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, legend); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + CHECK(text.find("class='legend'") != std::string::npos); + CHECK(get_legend_xy(text, bottom_x, bottom_y)); + CHECK(bottom_y > 140.0); + std::ofstream outs("testLegendPosition_bottom.svg"); + outs << text; + outs.flush(); + } + SECTION("Long vertical side legend fits panel height") { + const std::string longName(48, 'M'); + MolDraw2DSVG drawer(160, 90, -1, -1, NO_FREETYPE); + drawer.drawOptions().legendPosition = + MolDrawOptions::LegendPosition::Left; + drawer.drawOptions().legendVerticalText = true; + drawer.drawOptions().legendFraction = 0.22f; + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, longName); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + // At this size the fitted legend can be very small but should still be + // present in the SVG and within the panel. + CHECK(text.find("class='legend'") != std::string::npos); + std::ofstream outs("testLegendPosition_long_vertical.svg"); + outs << text; + outs.flush(); + } +} + +TEST_CASE("legend options from JSON", "[drawing]") { + auto m1 = "CCO"_smiles; + REQUIRE(m1); + SECTION("legendPosition and legendVerticalText parsed from JSON") { + const char *json = + R"({"legendPosition": "Top", "legendVerticalText": true})"; + MolDrawOptions opts; + MolDraw2DUtils::updateMolDrawOptionsFromJSON(opts, json); + CHECK(opts.legendPosition == MolDrawOptions::LegendPosition::Top); + CHECK(opts.legendVerticalText == true); + MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE); + drawer.drawOptions() = opts; + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, "Ethanol"); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + CHECK(text.find("class='legend'") != std::string::npos); + } + SECTION("legendPosition Left and legendFraction for side legend") { + MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE); + drawer.drawOptions().legendPosition = MolDrawOptions::LegendPosition::Left; + drawer.drawOptions().legendFraction = 0.25f; + drawer.drawOptions().legendVerticalText = true; + MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, "CCO"); + drawer.finishDrawing(); + auto text = drawer.getDrawingText(); + CHECK(text.find("class='legend'") != std::string::npos); + } +} + TEST_CASE( "Github 5061 - draw reaction with no reagents and scaleBondWidth true") { SECTION("basics") { diff --git a/Code/GraphMol/MolDraw2D/test1.cpp b/Code/GraphMol/MolDraw2D/test1.cpp index d7121369b..6790edc6b 100644 --- a/Code/GraphMol/MolDraw2D/test1.cpp +++ b/Code/GraphMol/MolDraw2D/test1.cpp @@ -151,10 +151,10 @@ static const std::map SVG_HASHES = { {"test21_2.svg", 3050176664U}, {"test22_1.svg", 3688394300U}, {"test22_2.svg", 1963311622U}, - {"testGithub3112_1.svg", 75452578U}, - {"testGithub3112_2.svg", 2379426157U}, - {"testGithub3112_3.svg", 102822156U}, - {"testGithub3112_4.svg", 497518508U}, + {"testGithub3112_1.svg", 1683049899U}, + {"testGithub3112_2.svg", 1452032589U}, + {"testGithub3112_3.svg", 3042134563U}, + {"testGithub3112_4.svg", 3952115653U}, {"testGithub3305_1.svg", 3688394300U}, {"testGithub3305_2.svg", 3172298658U}, {"testGithub3305_3.svg", 30441258U}, @@ -3858,11 +3858,11 @@ void testGithub3112() { #ifdef RDK_BUILD_FREETYPE_SUPPORT #if DO_TEST_ASSERT // this is the b (4th character) - TEST_ASSERT(text.find("b") != @@ -3884,11 +3884,11 @@ void testGithub3112() { #ifdef RDK_BUILD_FREETYPE_SUPPORT #if DO_TEST_ASSERT // this is the b on the 2nd line. - TEST_ASSERT(text.find("b") != @@ -3912,11 +3912,11 @@ void testGithub3112() { #ifdef RDK_BUILD_FREETYPE_SUPPORT #if DO_TEST_ASSERT // The first letter, N. - TEST_ASSERT(text.find("N") != @@ -3940,11 +3940,11 @@ void testGithub3112() { #ifdef RDK_BUILD_FREETYPE_SUPPORT #if DO_TEST_ASSERT // The first letter, N - TEST_ASSERT(text.find("N") != diff --git a/Docs/Book/Cartridge.md b/Docs/Book/Cartridge.md index 6560c4c29..42c6faf81 100644 --- a/Docs/Book/Cartridge.md +++ b/Docs/Book/Cartridge.md @@ -640,7 +640,7 @@ There are additional operators defined in the cartridge, but these are used for - mol\_to\_pkl(mol) : returns binary string (bytea) for a molecule. (*available from Q3 2012 (2012\_09) release*) - mol\_to\_ctab(mol,bool default true, bool default false) : returns a CTAB (mol block) string for a molecule. The optional second argument controls whether or not 2D coordinates will be generated for molecules that don't have coordinates. The optional third argument (available since the 2021\_09 release) controls whether or not a V3000 ctab should be generated. - mol\_to\_v3kctab(mol,bool default true) : returns a CTAB (mol block) string for a molecule. The optional second argument controls whether or not 2D coordinates will be generated for molecules that don't have coordinates (*available from 2021\_09 release*). -- mol\_to\_svg(mol,string default '',int default 250, int default 200, string default '') : returns an SVG with a drawing of the molecule. The optional parameters are a string to use as the legend, the width of the image, the height of the image, and a JSON with additional rendering parameters. (*available from the 2016\_09 release*) +- mol\_to\_svg(mol,string default '',int default 250, int default 200, string default '') : returns an SVG with a drawing of the molecule. The optional parameters are a string to use as the legend, the width of the image, the height of the image, and a JSON with additional rendering parameters. The JSON may include ``legendPosition`` (``"Top"``, ``"Left"``, ``"Right"``, ``"Bottom"``) and ``legendVerticalText`` (boolean, for side legends). (*available from the 2016\_09 release*) - mol\_to\_json(string) : returns the commonchem JSON for a molecule. (*available from the 2021\_09 release*) - mol\_from\_json(string) : returns a molecule for a commonchem JSON string, NULL if the molecule construction fails. (*available from the 2021\_09 release*) diff --git a/rdkit/Chem/Draw/__init__.py b/rdkit/Chem/Draw/__init__.py index eabdd55f7..c9219d8e3 100644 --- a/rdkit/Chem/Draw/__init__.py +++ b/rdkit/Chem/Draw/__init__.py @@ -68,7 +68,7 @@ if _sip_available(): def MolToImage(mol, size=(300, 300), kekulize=True, wedgeBonds=True, fitImage=False, options=None, **kwargs): - """Returns a PIL image containing a drawing of the molecule + """Returns a PIL image containing a drawing of the molecule. ARGUMENTS: @@ -84,6 +84,14 @@ def MolToImage(mol, size=(300, 300), kekulize=True, wedgeBonds=True, fitImage=Fa - highlightColor: RGB color as tuple (default [1, 0, 0]) + - legend: optional text drawn as legend (default ''). Position and style + are controlled by options (MolDrawOptions): legendPosition (Bottom, Top, + Left, Right), legendVerticalText (for Left/Right), legendFontSize, + legendFraction. Pass as options=opts or drawOptions=opts. + + - options: rdMolDraw2D.MolDrawOptions instance for drawing options + (e.g. legendPosition, legendVerticalText). Single-molecule only. + NOTE: use 'matplotlib.colors.to_rgb()' to convert string and @@ -139,6 +147,24 @@ def MolToFile(mol, filename, size=(300, 300), kekulize=True, wedgeBonds=True, im outf.close() +def MolToSVG(mol, size=(300, 300), kekulize=True, wedgeBonds=True, drawOptions=None, + **kwargs): + """Returns an SVG string containing a drawing of the molecule. + + Supports the same arguments as MolToImage for highlights and legend. + For legend position (Top/Left/Right/Bottom) and vertical text on side legends, + pass an rdMolDraw2D.MolDrawOptions instance with legendPosition and + legendVerticalText set. Legend position applies to single-molecule drawing; + for grid drawing (MolsToGridImage), pass the same drawOptions and the position + applies to each cell's legend. + """ + if not mol: + raise ValueError('Null molecule provided') + return _moltoSVG(mol, size, kwargs.get('highlightAtoms', []), kwargs.get('legend', ''), + kekulize, drawOptions=drawOptions, + highlightBonds=kwargs.get('highlightBonds', [])) + + def ShowMol(mol, size=(300, 300), kekulize=True, wedgeBonds=True, title='RDKit Molecule', stayInFront=True, **kwargs): """ Generates a picture of a molecule and displays it in a Tkinter window