Ir al contenido principal

Ralsina.Me — El sitio web de Roberto Alsina

Extendiendo rst2pdf y sphinx

Sor­ry, span­ish on­ly post!


Es­ti­ma­do públi­co lec­tor, una cosa muy es­pe­cial: un post que no es­cribí yo! Dé­mosle una bi­en­veni­da al au­tor in­vi­ta­do, Juan BC!


Una de mis prome­sas en el año fue ter­mi­nar uno de los proyec­tos mas am­bi­ciosos que me había prop­uesto: ter­mi­nar mi am­bi­entación de rol uti­lizan­do el sis­tema matemáti­co que había dis­eña­do jun­to con un ami­go tiem­po atras lla­ma­do Cros aprovechan­do la bar­bari­dad de im­a­genes que tenía guarda­da de mi tesis de gra­do.

Co­mo no puede ser de otra man­era co­mo miem­bro de Python Ar­genti­na util­ice el pro­gra­ma uti­liza­do por rst2pdf crea­do por Rober­to Alsi­na el cual es el dueño de este blog

En un pun­to nece­sita­ba hac­er una es­pecie de plan­til­las de dibu­jos para repe­tir el es­ti­lo donde hay de­scrip­ciones de un per­son­aje pero el es­ti­lo se mantiene, co­mo por ejem­p­lo las tar­je­tas de este man­u­al de DC Uni­verse RPG :

/static/cards_dc.jpg

y me di­je a mi mis­mo

¿Por que de­mo­ni­os no puedo dibu­jar un svg_ de­jar es­pa­cios en blan­co y luego los lleno den­tro del svg?

En defini­ti­va, me imag­in­a­ba al­go así:

.. template_svg: path/a/mi/archivo/svg/que/tiene/las/variables/definidas/adentro.svg
    :nombre_variable_1: valor_de_variable_1
    :nombre_variable_2: valor_de_variable_2
    ...
    :nombre_variable_N: valor_de_variable_N

En­tonces me puse en cam­paña para poder pro­gra­mar el códi­go python que re­al­iza esa tarea co­mo una ex­ten­sión de rst2pdf.

AVI­SO!!! este tex­to lo ll­e­vara a ust­ed por ca­da una de las desi­ciones de dis­eño que in­volu­cran la creación de mi ex­ten­sión para rst2pdf

Definiendo sintaxis y comportamiento

Por em­pezar aprendí de las lim­ita­ciones de la di­rec­ti­va, de­cidí un nom­bre para el­la y definí cual era el com­por­tamien­to que hi­ba a ten­er.

  • Da­do que una di­rec­ti­va co­mo tem­plate_svg me son­a­ba a larga de­cidí que el nom­bre sería svgt (SVG tem­plate).

  • Las di­rec­ti­vas so­por­tan parámet­ros fi­jos, no puedo ten­er un número vari­able de ar­gu­men­tos para pasar­le: nom­bre_­vari­able_1, nom­bre_­vari­able_2, vari­able_N a al­go co­mo di­rec­ti­­va(**k­wargs).

    Mi solu­­ción fue pasar­le to­­do en un json.

  • Por úl­ti­mo definí que svgt gener­aría un no­do de tipo fig­ure con lo cual mi di­rec­ti­va acep­taría, además de mis parámet­ros, los ar­gu­men­tos que ya tenía in­cor­po­ra­dos en dicha di­rec­ti­va.

    Es­ta úl­ti­ma de­cisión me llevó tam­bién a con­tem­plar que el archi­vo svg de­bería con­ver­tirse en un png, y para es­to uti­lizaría la her­ramien­ta Inkscape con una lla­ma­da al sis­tema para re­alizar dicha con­ver­sión (la gran ven­ta­ja de inkscape es que puede cor­rer to­tal­mente head­less con el parámetro -z).

En defini­ti­va mi di­rec­ti­va tomaría un no­do asi:

.. svgt:: file.svg
    :vars:
        { "nombre_variable_1": "valor_de_variable_1",
          "nombre_variable_2": "valor_de_variable_2",
          ...,
          "nombre_variable_N": "valor_de_variable_N"}

     <figure parameters>

Y lo con­vert­ería en un no­do así

.. figure:: file_con_parametros_resueltos.png
    <figure parameters>

Creando el template

Yo uso para crear svg a Inkscape y no pens­a­ba en ningún mo­men­to de­jar de hac­er­lo para crear este proyec­to. Así que so­lo era cuestión de abrir el pro­gra­ma y pon­er en la parte de donde se ref­er­en­cia a una url de una im­a­gen o en un tex­to al­gún for­ma­to que in­dique que eso es una vari­able (recorde­mos que por den­tro el svg no de­ja de ser tex­to plano).

Para elegir el lenguaje de template decidí utilizar el formato que propone la clase Template que viene en la librería estandar de; la cual dispone que las declaraciones de hacen anteponiendo el símbolo $ al nombre de la variable y pudiendo o no este nombre estar encerrado entre { y }.

Por ejem­p­lo en el sigu­iente im­a­gen se ve co­mo declaro dos vari­ables con Inkscape

/static/making_template.jpeg

Por otra parte inkscape des­de con­so­la se eje­cu­ta de la sigu­iente man­era para con­ver­tir un svg a png

::

$ inkscape -z file.svg -e file.p­ng -d 300

Don­de:

  • inkscape es el co­­man­­do para cor­r­er inkscape.

  • -z sirve para de­sha­­bil­i­­tar el en­­torno grá­­fi­­co.

  • file.svg es el archi­­vo a con­ver­tir.

  • -e file.p­ng in­di­ca que se va a ex­por­tar el archi­vo file.svg al for­ma­to png y se guardara en el archi­vo file.p­ng.

  • -d 300 dice que el archi­vo file.p­ng se creara con 300 dpi de cal­i­dad.

Con es­to me gen­ero los sigu­ientes prob­le­mas:

  • ¿Qué sucede si otro me pasa un tem­­plate y no se cuales son las var­i­ables que tiene aden­tro?

  • ¿Y si inkscape no es­­tá en el path de eje­cu­­ción?

  • ¿Y si no me gus­­tan los 300 dpi de cal­i­­dad?

A este pun­to ust­ed lec­tor ya se dara cuen­ta que to­do se tra­ta de agre­gar­le mas vari­ables a nues­tra sin­taxis ya definida: con lo cual to­do este cachibache quedaría así:

.. svgt:: file.svg
    :vars:
        { "nombre_variable_1": "valor_de_variable_1",
          "nombre_variable_2": "valor_de_variable_2",
          ...,
          "nombre_variable_N": "valor_de_variable_N"}
    :dpi: 72
    :inkscape_dir: /usr/bin
    :list_vars_and_exit:

     <figure parameters>

Sien­do:

  • :d­pi: 72 dice que el archi­­vo gen­er­a­­do para la figu­ra re­­sul­­tante ten­­dra 72d­pi.

  • :inkscape_dir: /us­r/bin in­di­ca que el co­man­do inkscape vive den­tro de la car­pe­ta /us­r/bin

  • :list_­vars_and_ex­it: es­tablece que svgt so­lo lis­tara por std­out la lista de var­i­ales ex­is­tente den­tro de file.svg y luego el pro­gra­ma ter­mi­nara (notese que so­lo tiene mo­tivos de de­bug­ing).

En códi­go python só­lo hay que crear un string con es­os hue­cos y luego llenar­los co­mo en el sigu­iente ejem­plo:

import os
cmd = "{isp} {svg} -z -e {png} -d {dpi}"
print cmd.format(isp=os.path.join("/usr/bin/", "inkscape"),
                 svg="file.svg",
                 png="file.png",
                 dpi="72")

[out] /usr/bin/inkscape file.svg -z -e file.png -d 72

A los bifes

Aho­ra si leyen­do el man­u­al de co­mo crear di­rec­ti­vas para do­cu­tils uno se en­cuen­tra que el es­quele­to mín­i­mo para crear es­tos bi­chos es el sigu­ien­te:

# imports necesarios
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst import Directive
from docutils.statemachine import StringList

# la directiva es una clase
class SVGTemplate(Directive):
    """ The svgt directive"""

    # cuantos argumentos obligatorios tenemos
    required_arguments = 0

    # cuantos argumentos opcionales
    optional_arguments = 0

    # si la directiva termina con una linea en blanco
    final_argument_whitespace = False

    # funciones de validación para los argumentos
    option_spec = {}

    # si puede tener mas rst adentro de la directiva
    has_content = True

    # método que hace el procesamiento
    def run(self):
        return []

# le pone nombre a la directiva y la registra
directives.register_directive("svgt", SVGTemplate)

Los parámetros

Primero em­pece­mos definien­do un dic­cionario que rela­ciona ca­da nom­bre de parámetro (los cuales yo defi­no que son TO­DOS op­cionales) con una fun­ción que los val­i­da. Por otra parte da­do la es­truc­tura que quer­e­mos gener­ar de fig­ure posee con­tenido y la nues­tra tam­bién debe poseer­lo.

import json

# docutils imports

SPECS = {

    # variables mías y funciones que la validan y convierten en datos utiles
    # para nuestro procesamiento

    # convierte vars a un json
    "vars": json.loads,
    # convierte dpi a entero positivo
    "dpi": directives.nonnegative_int,
    # preprocesa el directorio quitandole espacios finales e iniciales
    # en blanco
    "inkscape_dir": directives.path,
    # se fija que por ser una bandera no tenga ni un valor asignado
    # si eso sucede retorna None sino tira un ValueError
    "list_vars_and_exit": directives.flag,

    # variables de figure
    "alt": directives.unchanged,
    "height": directives.length_or_percentage_or_unitless,
    "width": directives.length_or_percentage_or_unitless,
    "scale": directives.percentage,
    "align": lambda align: directives.choice(align, ('left', 'center', 'right')),
    "target": directives.uri,
    "class": directives.unchanged,
    "name": directives.unchanged,
    "figwidth": directives.length_or_percentage_or_unitless,
    "figclass": directives.unchanged,
}

# la directiva es una clase
class SVGTemplate(Directive):
    """ The svgt directive"""

    required_arguments = 0

    # cuantos argumentos opcionales
    optional_arguments = len(SPECS)

    final_argument_whitespace = False

    # funciones de validacion para los argumentos
    option_spec = SPECS

    has_content = True

    def run(self):
        return []

directives.register_directive("svgt", SVGTemplate)

Todas las funciones de validación (menos json.load) estan muy bien explicadas en la documentación de docutils .

Entendiendo los Nodos

La creación de nodos se hace con funciones que habitan en el modulo from docutils import nodes y todos los nodos se crean así (tomando de ejemplo figure):

my_new_node = figure(self, rawsource='', *children, **attributes)

don­de:

  • raw­­source: es el codi­­go fuente del no­­do que sirve para mo­­tivos de de­bug­in­ng, si salta un er­ror te mues­­tra donde es­­­ta la fal­la basan­­dose en este string. Así que mien­­tras mas rep­re­sen­­ta al no­­do este ar­gu­­men­­to mejor.

  • *chil­­dren: son los no­­dos hi­jos.

  • **at­tribute: los atrib­u­­tos y op­­ciones del nue­­vo no­­do. si se pasa una op­­cion in­­­val­i­­da sim­­ple­­mente se ig­no­ra.

Entendienfo figure

Las fig­uras son un no­do que con­tiene 3 hi­jos:

  • im­age que con­­tiene la im­a­­gen propi­a­­mente dicha.

  • cap­­tion que es un pár­rafo que le da una eti­que­­ta a la im­a­­gen.

  • leg­end que con­­tiene to­­do los par­rafos so­brantes que no son el cap­­tion

Os­ea al­go asi:

+---------------------------+
|        figure             |
|                           |
|<------ figwidth --------->|
|                           |
|  +---------------------+  |
|  |     image           |  |
|  |                     |  |
|  |<--- width --------->|  |
|  +---------------------+  |
|                           |
|The figure's caption should|
|wrap at this width.        |
|                           |
|Legend                     |
+---------------------------+

Con es­to en mente y con el con­cep­to de que TO­DO NO­DO DEBE SER PROCE­SA­DO O DE­STRU­I­DO MAN­UAL­MENTE va­mos ya a en­car­ar el al­go­rit­mo.

Explicando el método run

La fun­ción run só­lo tiene una condi­ción: debe de­volver un lista que con­tiene no­dos de do­cu­tils a ser ren­der­iza­dos

Así que a mano alza­da run sería al­go mas o menos así:

def run(self):
    # sacamos la direccion donde se encuentra el svg
    uri = self.arguments[0]

    # extraemos nuestras variables y les asignamos un valor por defecto en
    # caso de no existir
    options = dict(self.options)
    svgt_vars = options.pop("vars") if "vars" in options else {}
    svgt_dpi = options.pop("dpi") if "dpi" in options else 72
    svgt_isd = options.pop("inkscape_dir") if "inkscape_dir" in options else ""
    svgt_lvae = options.pop("list_vars_and_exit") == None \
                if "list_vars_and_exit" in options else False

    # si tenemos seteado el flag list_vars_and_exit mostramos las variables
    # y salimos del programa
    if svgt_lvae:
        self._show_vars(uri)
        sys.exit(0)

    # por como esta diseñado figure hay que evitar que la propiedad align
    # le llege a su imagen interior.
    fig_align = options.pop("align") if "align" in options else None

    # pasamos a crear el archivo png
    png_path = self._render_svg(uri, svgt_isd, svgt_dpi, svgt_vars)

    # agremamos la uri del png a las opciones
    options["uri"] = png_path

    # creamos el nodo imagen
    image_node = nodes.image(self.block_text, **options)

    # el contenido de caption y legend viene todo mezclado
    # como un iterable llamado docutils.statemachine.StringList
    # hay que separarlo en dos partes para crear la figure.
    caption_content, legend_content = self._separate_content(self.content)

    # creamos el nodo caption procesando su contenido con
    # nested_parse
    caption_node = nodes.caption("\n".join(caption_content))
    self.state.nested_parse(caption_content, self.content_offset, caption_node)

    # creamos el nodo legend y procesamos su contenido
    legend_node = nodes.legend("\n".join(legend_content))
    self.state.nested_parse(legend_content, self.content_offset, legend_node)

    # restautamos la variable align para crear el nodo figure
    if fig_align != None:
        options["align"] = fig_align

    # creaamos el susodicho nodo figure pasandole sus hijos
    figure_node = nodes.figure(self.block_text, image_node,
                               caption_node, legend_node, **options)

    # retornamos una lista con el nodo resultante
    return [figure_node]

Explicando el método _render_svg

Este método utiliza otros dos, _resolve_render_name que sera explicado después y el método _call que debido a su extrema simplicidad se recomienda leer directamente la documentació del clase subprocess.Popen

CMD = "{isp} {svg} -z -e {png} -d {dpi}"

# donde:
#   - uri es la direccion donde esta el svg a renderizar
#   - svgt_isd es el lugar donde esta inkscape
#   - svgt_dpi son los dpi del png a renderizar
#   - svgt_vars es un diccionario que contiene los valores de las variables del svg
def _render_svg(self, uri, svgt_isd, svgt_dpi, svgt_vars):

    # abrimos el svg y lo cargamos en un Template
    with open(uri) as fp:
        svg_tplt = string.Template(fp.read())

    # reemplazamos las variables con sus valores
    svg_src = svg_tplt.safe_substitute(svgt_vars)

    # obtenemos paths donde guardar:
    #   - El svg con las variables reemplazadas (fname_svg)
    #   - El png resultante (fname_png)
    fname_svg, fname_png = self._resolve_render_name(uri)

    # guardamos el svg reemplazado en su lugar
    with open(fname_svg, "w") as fp:
        fp.write(svg_src)

    # Ponemos los parametros de ejecucion de inkscape
    cmd = CMD.format(isp=os.path.join(svgt_isd, "inkscape"),
                     svg=fname_svg,
                     png=fname_png,
                     dpi=svgt_dpi)

    # ejecutamos inkscape
    self._call(cmd)

    # retornamos la direccion del nuevo png
    return fname_png

El método _resolve_render_name

Este méto­do a primera vista es in­ece­sar­i­o, ya que po­dríamos gener­ar archivos tem­po­rales efi­cien­te­mente con el mod­u­lo _tem­plate, pero da­do co­mo tra­ba­ja sphinx es­to no es posi­ble de primera mano.

# uri es la direccioón del svg original
def _resolve_render_name(self, uri):

    # leemos el directorio temporal de la variable global _tempdir
    # que pudo haber sido modificada por sphinx o tomamos el dir temporal
    # del sistema operativo. Esto se debe a que sphinx corre en un sandbox
    # y todos los archivos deben estar en la misma carpeta donde se
    # encuentra el archivo de configuración conf.py
    tempdir = _tempdir if _tempdir != None else tempfile.gettempdir()

    # extraemos el nombre del archivo sin su extensión
    basename = os.path.basename(uri).rsplit(".", 1)[0]

    # agregamos al directorio temporal el nombre del archivo sin la
    # extensión
    render_name = os.path.join(tempdir, basename)

    # generamos un nuevo nombre para el archivo svg agregando un entero
    # al final para evitar coliciones
    idx = ""
    unique_svg = render_name + "{idx}" + ".svg"
    while os.path.exists(unique_svg.format(idx=idx)):
       idx = idx + 1 if isinstance(idx, int) else 1
    unique_svg = unique_svg.format(idx=idx)

    # lo mismo para el png
    idx = ""
    unique_png = render_name + "{idx}" + ".png"
    while os.path.exists(unique_png.format(idx=idx)):
       idx = idx + 1 if isinstance(idx, int) else 1
    unique_png = unique_png.format(idx=idx)

    # retornamos los dos valores
    return unique_svg, unique_png

El método _separate_content

Este es el último método que utiliza el método run y es lo último que necesitamos para hacer el trabajo de svgt

# recibe por parámetro un string list con todos los nodos de texto del
# contenido de svgt
def _separate_content(self, content):

    # primero creamos un nodo stringlist exclusivamente para el caption
    caption_cnt = StringList(parent=content.parent,
                           parent_offset=content.parent_offset)

    # ahora algo IMPORTANTE.. la leyenda va a ser el mismo nodo contenido
    # por que la otra alternativa es crear uno nuevo y borrar el viejo
    # por que TODO NODO TIENE QUE SER PROCESADO O ELIMINADO MANUALMENTE
    legend_cnt = content # all nodes need to be procesed

    # si tenemos contenido copiamos el primer elemento al caption y quitamos
    # ese elemento de la leyenda (que es lo mismo que el content)
    if content:
        caption_cnt.append(content[0], content.source(0), content.offset(0))
        content.pop(0)
    return caption_cnt, legend_cnt

Pensando en sphinx

Para que este bicho funcione en sphinx es necesario agregar a nivel de modulo una función setup que recibe un único parámetro que es la instancia de sphinx corriendo.

def setup(app):

    # toma el valor la referencia a la variable de módulo _tempdir
    global _tempdir

    # esta función reci be dos parámetros requeridos para usarse como
    # slot de la señal build-finished de sphinx y sirve para limpiar
    # el directorio temporal.
    #   - la aplicación que ejecuto la señal
    #   - la exception que genero el fin del procesamiento o None si
    #     finalizo normalmente.
    def reset(app, ex):
        if _tempdir != None and os.path.isdir(_tempdir):
            shutil.rmtree(_tempdir)
        if ex:
            raise ex

    # agregamos una configuracion mas a sphinx llamada tempdir y asignamos
    # como valor por defecto _temp
    app.add_config_value("tempdir", "_temp", "")

    # tomamos el valor de tempdir de la configuración
    _tempdir = app.config["tempdir"]

    # reiniciamos el directorio temporal
    reset(app, None)

    # lo volvemos a crear
    os.mkdir(_tempdir)

    # conectamos la señal build-finished con la función reset
    app.connect("build-finished", reset)

Si de­sean mas in­for­ma­ción lean la doc­u­mentación del api de ex­ten­siones de sphinx

Y como se usa todo esto

Bueno con rst2pdf o bi­en lo tiran en la car­pe­ta de ex­ten­siones, o en el path donde tienen su rst y luego eje­cu­tan

$ rst2pdf archivo.rst -e svgt

Tam­bién pueden in­sta­lar­lo des­de pypi con easy_in­stall o pip con los co­man­dos:

$ pip install docutils_ext

o

$ easy_install docutils_ext

En el ca­so de sphinx, tienen que agre­gar el path donde se en­cuen­tre a sys.­path y luego agre­gar­la a la lista de ex­ten­siones.

Y funciona?

Pueden ver el códi­go com­ple­to al mo­men­to de la pub­li­cación de este artícu­lo aca además de poder descar­gar la úl­ti­ma ver­sión es­table en la pes­tañi­ta de down­load­s.

Lo de aca aba­jo dice

.. svgt:: img/temp.svg
    :vars: {"name": "that's all folks", "url": "img/troll.png"}
troll

Contents © 2000-2024 Roberto Alsina