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
This commit is contained in:
Brandon Novy
2026-04-15 22:59:00 -04:00
committed by GitHub
parent d8f4afb558
commit efa7a32c3c
10 changed files with 680 additions and 114 deletions

View File

@@ -12,6 +12,7 @@
#include <algorithm>
#include <limits>
#include <boost/algorithm/string.hpp>
#include <Geometry/Transform2D.h>
#include <Geometry/Transform3D.h>
@@ -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<double>(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<std::string> &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<std::string> split_legend_bits(const std::string &legend,
bool vertText) {
std::vector<std::string> 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<std::string> &legend_bits,
double relFontScale, const DrawColour &legendColour,
DrawText &textDrawer, double baseX, double baseY,
double drawWidth, double drawHeight,
std::vector<std::unique_ptr<DrawAnnotation>> &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<int>(legends.size()) - 1; i >= 0; --i) {
xmin = ymin = std::numeric_limits<double>::max();
xmax = ymax = std::numeric_limits<double>::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<int>(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<std::string> &legend_bits,
double relFontScale, const DrawColour &legendColour,
DrawText &textDrawer, double baseX, double baseY,
double drawWidth, double drawHeight, int legendHeight,
std::vector<std::unique_ptr<DrawAnnotation>> &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<std::string> &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<std::unique_ptr<DrawAnnotation>> &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<double>(legendWidth);
stripCentreX = molPanelLeft - pad - maxLineW / 2.0;
stripCentreX = std::max(stripCentreX, baseX + maxLineW / 2.0 + 2.0);
} else {
const double molPanelRight = baseX + static_cast<double>(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<std::string> &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<std::string> 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<std::string> 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<double>::max();
xmax = ymax = std::numeric_limits<double>::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<double>::max();
xmax = ymax = std::numeric_limits<double>::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<double>(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<double>(n) * max_h +
(n > 1 ? static_cast<double>(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<double>(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<double>(drawWidth_));
xCentre = basePanelX + double(molWidth_) - g - scaledRanges.x;
}
break;
}
toCentre = Point2D(xCentre, yCentre);
}
// ****************************************************************************

View File

@@ -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

View File

@@ -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

View File

@@ -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<std::string>();
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;

View File

@@ -788,6 +788,13 @@ BOOST_PYTHON_MODULE(rdMolDraw2D) {
.value("Lasso", RDKit::MultiColourHighlightStyle::LASSO)
.export_values();
python::enum_<RDKit::MolDrawOptions::LegendPosition>("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_<RDKit::DrawElement::_enumerated> 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.")

View File

@@ -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()

View File

@@ -74,9 +74,9 @@ const std::map<std::string, std::hash_result_t> 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<std::string, std::hash_result_t> 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(
"<text x='(-?[0-9]+\\.?[0-9]*)' y='(-?[0-9]+\\.?[0-9]*)' class='legend'");
if (std::regex_search(text, match, textRgx) && match.size() == 3) {
x = std::stod(match[1].str());
y = std::stod(match[2].str());
return true;
}
std::regex pathRgx("class='legend' d='M (-?[0-9]+\\.?[0-9]*) "
"(-?[0-9]+\\.?[0-9]*)");
if (std::regex_search(text, match, pathRgx) && match.size() == 3) {
x = std::stod(match[1].str());
y = std::stod(match[2].str());
return true;
}
return false;
};
auto get_all_legend_xy = [](const std::string &text) {
std::vector<std::pair<double, double>> coords;
std::regex textRgx(
"<text x='(-?[0-9]+\\.?[0-9]*)' y='(-?[0-9]+\\.?[0-9]*)' class='legend'");
for (auto it = std::sregex_iterator(text.begin(), text.end(), textRgx);
it != std::sregex_iterator(); ++it) {
coords.emplace_back(std::stod((*it)[1].str()), std::stod((*it)[2].str()));
}
if (!coords.empty()) {
return coords;
}
std::regex pathRgx("class='legend' d='M (-?[0-9]+\\.?[0-9]*) "
"(-?[0-9]+\\.?[0-9]*)");
for (auto it = std::sregex_iterator(text.begin(), text.end(), pathRgx);
it != std::sregex_iterator(); ++it) {
coords.emplace_back(std::stod((*it)[1].str()), std::stod((*it)[2].str()));
}
return coords;
};
double top_x = 0.0, top_y = 0.0;
double bottom_x = 0.0, bottom_y = 0.0;
double left_x = 0.0, left_y = 0.0;
double right_x = 0.0, right_y = 0.0;
SECTION("Top") {
MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE);
drawer.drawOptions().legendPosition =
MolDrawOptions::LegendPosition::Top;
MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, legend);
drawer.finishDrawing();
auto text = drawer.getDrawingText();
CHECK(text.find("class='legend'") != std::string::npos);
CHECK(get_legend_xy(text, top_x, top_y));
// Legend should sit in the upper part of the canvas (SVG y grows down).
CHECK(top_y < 50.0);
std::ofstream outs("testLegendPosition_top.svg");
outs << text;
outs.flush();
}
SECTION("Left with vertical text") {
MolDraw2DSVG drawer(200, 200, -1, -1, NO_FREETYPE);
drawer.drawOptions().legendPosition =
MolDrawOptions::LegendPosition::Left;
drawer.drawOptions().legendVerticalText = true;
MolDraw2DUtils::prepareAndDrawMolecule(drawer, *m1, legend);
drawer.finishDrawing();
auto text = drawer.getDrawingText();
CHECK(text.find("class='legend'") != std::string::npos);
auto coords = get_all_legend_xy(text);
REQUIRE(coords.size() > 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") {

View File

@@ -151,10 +151,10 @@ static const std::map<std::string, std::hash_result_t> 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("<path class='legend' d='M 134.6 185.3") !=
TEST_ASSERT(text.find("<path class='legend' d='M 130.1 179.1") !=
std::string::npos);
#endif
#else
TEST_ASSERT(text.find("<text x='120.5' y='190.0' class='legend' "
TEST_ASSERT(text.find("<text x='121.0' y='190.0' class='legend' "
"style='font-size:16px;font-style:normal;font-weight:"
"normal;fill-opacity:1;stroke:none;font-family:sans-"
"serif;text-anchor:start;fill:#000000' >b</text>") !=
@@ -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("<path class='legend' d='M 119.7 184.3") !=
TEST_ASSERT(text.find("<path class='legend' d='M 120.2 184.3") !=
std::string::npos);
#endif
#else
TEST_ASSERT(text.find("<text x='111.1' y='190.0' class='legend' "
TEST_ASSERT(text.find("<text x='111.6' y='190.0' class='legend' "
"style='font-size:11px;font-style:normal;font-weight:"
"normal;fill-opacity:1;stroke:none;font-family:sans-"
"serif;text-anchor:start;fill:#000000' >b</text>") !=
@@ -3912,11 +3912,11 @@ void testGithub3112() {
#ifdef RDK_BUILD_FREETYPE_SUPPORT
#if DO_TEST_ASSERT
// The first letter, N.
TEST_ASSERT(text.find("<path class='legend' d='M 12.9 182.6") !=
TEST_ASSERT(text.find("<path class='legend' d='M 13.4 182.6") !=
std::string::npos);
#endif
#else
TEST_ASSERT(text.find("<text x='9.7' y='190.0' class='legend' "
TEST_ASSERT(text.find("<text x='10.2' y='190.0' class='legend' "
"style='font-size:9px;font-style:normal;font-weight:"
"normal;fill-opacity:1;stroke:none;font-family:sans-"
"serif;text-anchor:start;fill:#000000' >N</text>") !=
@@ -3940,11 +3940,11 @@ void testGithub3112() {
#ifdef RDK_BUILD_FREETYPE_SUPPORT
#if DO_TEST_ASSERT
// The first letter, N
TEST_ASSERT(text.find("<path class='legend' d='M 59.9 172.5") !=
TEST_ASSERT(text.find("<path class='legend' d='M 60.4 172.5") !=
std::string::npos);
#endif
#else
TEST_ASSERT(text.find("<text x='57.2' y='181.0' class='legend' "
TEST_ASSERT(text.find("<text x='57.7' y='181.0' class='legend' "
"style='font-size:11px;font-style:normal;font-weight:"
"normal;fill-opacity:1;stroke:none;font-family:sans-"
"serif;text-anchor:start;fill:#000000' >N</text>") !=