Depicting CSV or SDF in PDF

Problem

You want to depict molecules along with their associated data read from a CSV file in a multi-page PDF file. See example in drugs.pdf and in Table 1.

Table 1. Example of depiction of CSV in PDF (The pages are reduced here for visualization convenience)

page 1

page 2

../_images/csv2pdf-01-01.svg ../_images/csv2pdf-01-02.svg

Ingredients

Difficulty Level

🌶️

Download

Download code

csv2pdf.py

See also Usage subsection.

Source Code

csv2pdf
#!/usr/bin/env python3
# (C) 2026 Cadence Design Systems, Inc. (Cadence)
# All rights reserved.
# TERMS FOR USE OF SAMPLE CODE The software below ("Sample Code") is
# provided to current licensees or subscribers of OpenEye products or
# SaaS offerings (each a "Customer").
# Customer is hereby permitted to use, copy, and modify the Sample Code,
# subject to these terms. Cadence claims no rights to Customer's
# modifications. Modification of Sample Code is at Customer's sole and
# exclusive risk. Sample Code may require Customer to have a then
# current license or subscription to the applicable OpenEye offering.
# THE SAMPLE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED.  CADENCE DISCLAIMS ALL WARRANTIES, INCLUDING, BUT
# NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
# PARTICULAR PURPOSE AND NONINFRINGEMENT. In no event shall Cadence be
# liable for any damages or liability in connection with the Sample Code
# or its use.

"""Converts a CSV or SDF file into PDF with molecular depictions."""

import argparse
import os
import pathlib
import sys

from openeye import oechem, oedepict
from rich_argparse import HelpPreviewAction, RichHelpFormatter

__SCRIPT_NAME__ = pathlib.Path(__file__).absolute().stem
__SCRIPT_DESC__ = "Convert CSV or SDF files into PDF reports with molecular depictions"
__SCRIPT_TOOLKITS__ = ["oechem", "oedepict", "depiction"]
__SCRIPT_CATEGORIES__ = ["depiction"]


def parse_options() -> argparse.Namespace:
    """Set up command line options."""
    parser = argparse.ArgumentParser(
        add_help=True,
        formatter_class=RichHelpFormatter,
        description="[yellow]" + __SCRIPT_DESC__ + "[/yellow]",
    )

    io_group = parser.add_argument_group("Input options")
    io_group.add_argument(
        "--mol",
        "--mol-file",
        metavar="MOL-FILE",
        type=str,
        required=True,
        help="input molecule file (.csv or .sdf)",
    )

    report_group = parser.add_argument_group("Report options")
    report_group.add_argument(
        "--report",
        type=str,
        required=True,
        metavar="REPORT-FILE",
        help="output report file (.pdf)",
    )
    report_group.add_argument(
        "--rows",
        type=int,
        default=3,
        choices=range(1, 4),
        metavar="N",
        help="number of rows per page (default: %(default)s)",
    )
    report_group.add_argument(
        "--page-by-page",
        action="store_true",
        help="write pages of report to separate numbered image files (default: %(default)s)",
    )

    parser.add_argument("--help-image", action=HelpPreviewAction)
    parser.add_argument(
        "--save-console-svg",
        default=False,
        action="store_true",
        help=f"run command and capture console output in {__SCRIPT_NAME__}.svg file",
    )
    return parser.parse_args()


def main() -> int:
    """Convert molecule file to PDF report."""
    args = parse_options()

    _check_report_file(args)

    mol_list: list[oechem.OEMolBase] = read_molecules(args.mol)
    tags: list[str] = collect_data_tags(mol_list)

    rows, cols = args.rows, 2
    report_opts: oedepict.OEReportOptions = oedepict.OEReportOptions(rows, cols)
    report_opts.SetHeaderHeight(25)
    report_opts.SetFooterHeight(25)
    report_opts.SetCellGap(2)
    report_opts.SetPageMargins(10)
    report: oedepict.OEReport = oedepict.OEReport(report_opts)

    cell_width, cell_height = report.GetCellWidth(), report.GetCellHeight()
    opts: oedepict.OE2DMolDisplayOptions = oedepict.OE2DMolDisplayOptions(
        cell_width, cell_height, oedepict.OEScale_AutoScale
    )

    depict_molecules_with_data(
        report, mol_list, pathlib.Path(args.mol).name, tags, opts
    )

    if args.page_by_page:
        oedepict.OEWriteReportPageByPage(args.report, report)
    else:
        oedepict.OEWriteReport(args.report, report)

    return os.EX_OK


def read_molecules(mol_filename: str) -> list[oechem.OEMolBase]:
    """Read molecules from a CSV or SDF file and return a list of OEMolBase objects."""
    mol_path = pathlib.Path(mol_filename)
    if not mol_path.exists():
        oechem.OEThrow.Fatal(f"Cannot open input file '{mol_path.name}'!")

    ifs = oechem.oemolistream()
    if not ifs.open(str(mol_path)):
        oechem.OEThrow.Fatal(f"Cannot open input file '{mol_path.name}'!")

    if ifs.GetFormat() not in [oechem.OEFormat_CSV, oechem.OEFormat_SDF]:
        oechem.OEThrow.Fatal("Input must be a CSV or SDF file!")
    mol_list: list[oechem.OEMolBase] = [
        oechem.OEGraphMol(m) for m in ifs.GetOEGraphMols()
    ]
    return mol_list


def collect_data_tags(mol_list: list[oechem.OEMolBase]) -> list[str]:
    """Collect all unique SD data tags from molecules."""
    tags: list[str] = []
    for mol in mol_list:
        for dp in oechem.OEGetSDDataIter(mol):
            tag: str = dp.GetTag()
            if tag not in tags:
                tags.append(tag)
    return tags


def depict_molecules_with_data(
    report: oedepict.OEReport,
    mol_list: list[oechem.OEMolBase],
    input_name: str,
    tags: list[str],
    opts: oedepict.OE2DMolDisplayOptions,
) -> None:
    """Depict molecules with their associated data in report."""
    for mol in mol_list:
        cell: oedepict.OEImageBase = report.NewCell()
        oedepict.OEPrepareDepiction(mol)
        disp: oedepict.OE2DMolDisplay = oedepict.OE2DMolDisplay(mol, opts)
        oedepict.OERenderMolecule(cell, disp)
        oedepict.OEDrawCurvedBorder(cell, oedepict.OELightGreyPen, 10.0)

        # Render corresponding data
        cell = report.NewCell()
        render_data(cell, mol, tags)

    # Add input filename to headers
    header_font: oedepict.OEFont = oedepict.OEFont(
        oedepict.OEFontFamily_Default,
        oedepict.OEFontStyle_Default,
        12,
        oedepict.OEAlignment_Center,
        oechem.OEBlack,
    )
    header_pos: oedepict.OE2DPoint = oedepict.OE2DPoint(
        report.GetHeaderWidth() / 2.0, report.GetHeaderHeight() / 2.0
    )

    for header in report.GetHeaders():
        header.DrawText(header_pos, input_name, header_font)

    # Add page number to footers
    footer_font: oedepict.OEFont = oedepict.OEFont(
        oedepict.OEFontFamily_Default,
        oedepict.OEFontStyle_Default,
        12,
        oedepict.OEAlignment_Center,
        oechem.OEBlack,
    )
    footer_pos: oedepict.OE2DPoint = oedepict.OE2DPoint(
        report.GetFooterWidth() / 2.0, report.GetFooterHeight() / 2.0
    )

    for page_idx, footer in enumerate(report.GetFooters()):
        footer.DrawText(footer_pos, f"- {page_idx + 1} -", footer_font)


def render_data(
    image: oedepict.OEImageBase, mol: oechem.OEMolBase, tags: list[str]
) -> None:
    """Render SD data as a table in the image."""
    data: list[tuple[str, str]] = []
    for tag in tags:
        value: str = "N/A"
        if oechem.OEHasSDData(mol, tag):
            value = oechem.OEGetSDData(mol, tag)
        data.append((tag, value))

    nr_data: int = len(data)

    table_opts: oedepict.OEImageTableOptions = oedepict.OEImageTableOptions(
        nr_data, 2, oedepict.OEImageTableStyle_LightBlue
    )
    table_opts.SetColumnWidths([10, 20])
    table_opts.SetMargins(2.0)
    table_opts.SetHeader(False)
    table_opts.SetStubColumn(True)
    table: oedepict.OEImageTable = oedepict.OEImageTable(image, table_opts)

    for row, (tag, value) in enumerate(data):
        cell: oedepict.OEImageBase = table.GetCell(row + 1, 1)
        table.DrawText(cell, f"{tag}:")
        cell = table.GetBodyCell(row + 1, 1)
        table.DrawText(cell, value)


def _check_report_file(args: argparse.Namespace) -> bool:
    ext = pathlib.Path(args.report).suffix[1:]
    if not oedepict.OEIsRegisteredImageFile(ext):
        oechem.OEThrow.Fatal("Unknown image output type!")

    if not args.page_by_page and not oedepict.OEIsRegisteredMultiPageImageFile(ext):
        oechem.OEThrow.Warning("Report will be generated into separate pages!")
        args.page_by_page = True

    return True


setattr(main, "__SCRIPT_NAME__", __SCRIPT_NAME__)
setattr(main, "__SCRIPT_DESC__", __SCRIPT_DESC__)
setattr(main, "__SCRIPT_TOOLKITS__", __SCRIPT_TOOLKITS__)
setattr(main, "__SCRIPT_CATEGORIES__", __SCRIPT_CATEGORIES__)

if __name__ == "__main__":
    sys.exit(main())

Solution

The CSV file format is a text file format containing comma-separated values. In OEChem TK, this file format is implemented to enable data exchange with a wide variety of other software. Each line of a CSV file stores data for a molecule that is represented by a SMILES string.

See also

When reading a CSV file, the fields of the file are attached to each molecule as SD data. This data can be accessed by the OEGetSDDataIter function that returns an iterator over all the SD data (tag - value) pairs of a molecule. The collect_data_tags function iterates over a list of molecules and returns the unique tags of the data attached to the molecules.

1def collect_data_tags(mol_list: list[oechem.OEMolBase]) -> list[str]:
2    """Collect all unique SD data tags from molecules."""
3    tags: list[str] = []
4    for mol in mol_list:
5        for dp in oechem.OEGetSDDataIter(mol):
6            tag: str = dp.GetTag()
7            if tag not in tags:
8                tags.append(tag)
9    return tags

The depict_molecules_with_data function takes a list of molecules read from a CSV file along with the data tags returned by the collect_data_tags function. Each molecule and its corresponding data is rendered into adjacent cells of an OEReport object. The OEReport class is a layout manager allowing the generation of multi-page images in a convenient way. After rendering the molecules, the input filename is rendered into page headers while the page number is rendered at the bottom of each page.

def depict_molecules_with_data(
    report: oedepict.OEReport,
    mol_list: list[oechem.OEMolBase],
    input_name: str,
    tags: list[str],
    opts: oedepict.OE2DMolDisplayOptions,
) -> None:
    """Depict molecules with their associated data in report."""
    for mol in mol_list:
        cell: oedepict.OEImageBase = report.NewCell()
        oedepict.OEPrepareDepiction(mol)
        disp: oedepict.OE2DMolDisplay = oedepict.OE2DMolDisplay(mol, opts)
        oedepict.OERenderMolecule(cell, disp)
        oedepict.OEDrawCurvedBorder(cell, oedepict.OELightGreyPen, 10.0)

        # Render corresponding data
        cell = report.NewCell()
        render_data(cell, mol, tags)

    # Add input filename to headers
    header_font: oedepict.OEFont = oedepict.OEFont(
        oedepict.OEFontFamily_Default,
        oedepict.OEFontStyle_Default,
        12,
        oedepict.OEAlignment_Center,
        oechem.OEBlack,
    )
    header_pos: oedepict.OE2DPoint = oedepict.OE2DPoint(
        report.GetHeaderWidth() / 2.0, report.GetHeaderHeight() / 2.0
    )

    for header in report.GetHeaders():
        header.DrawText(header_pos, input_name, header_font)

    # Add page number to footers
    footer_font: oedepict.OEFont = oedepict.OEFont(
        oedepict.OEFontFamily_Default,
        oedepict.OEFontStyle_Default,
        12,
        oedepict.OEAlignment_Center,
        oechem.OEBlack,
    )
    footer_pos: oedepict.OE2DPoint = oedepict.OE2DPoint(
        report.GetFooterWidth() / 2.0, report.GetFooterHeight() / 2.0
    )

    for page_idx, footer in enumerate(report.GetFooters()):
        footer.DrawText(footer_pos, f"- {page_idx + 1} -", footer_font)

The render_data function shows how easy it is to render the (tag - value) tuples using the OEImageTable class.

def render_data(
    image: oedepict.OEImageBase, mol: oechem.OEMolBase, tags: list[str]
) -> None:
    """Render SD data as a table in the image."""
    data: list[tuple[str, str]] = []
    for tag in tags:
        value: str = "N/A"
        if oechem.OEHasSDData(mol, tag):
            value = oechem.OEGetSDData(mol, tag)
        data.append((tag, value))

    nr_data: int = len(data)

    table_opts: oedepict.OEImageTableOptions = oedepict.OEImageTableOptions(
        nr_data, 2, oedepict.OEImageTableStyle_LightBlue
    )
    table_opts.SetColumnWidths([10, 20])
    table_opts.SetMargins(2.0)
    table_opts.SetHeader(False)
    table_opts.SetStubColumn(True)
    table: oedepict.OEImageTable = oedepict.OEImageTable(image, table_opts)

    for row, (tag, value) in enumerate(data):
        cell: oedepict.OEImageBase = table.GetCell(row + 1, 1)
        table.DrawText(cell, f"{tag}:")
        cell = table.GetBodyCell(row + 1, 1)
        table.DrawText(cell, value)

Usage

See Download section to download the script.

> csv2pdf --help
../_images/csv2pdf-help.svg

Running the above command with drugs.csv will generate the drugs.pdf multi-page pdf file.

> csv2pdf --mol drugs.csv --report report.pdf

Discussion

Reading the columns of a CSV file into SD data fields means that the OEChem TK provides a meta-data interchange between sdf files and CSV files. Consequently, the same Python script can be used to generate a pdf file reading an sdf file.

Running the above command with drugs.sdf will generate the same drugs.pdf multi-page pdf file.

> csv2pdf --mol drugs.sdf --report report.pdf

See also in OEChem TK manual

Theory

API

See also in OEDepict TK manual

Theory

API