Symfony notes – building the Download Portal

Another useful tool we try to use is the MakerBundle. We start by making a Category editor.

$ bin/console make:crud Category

Then we have to adjust the URLs, add the fact that categories are always defined “by project”, and so on, by editing the Controller and the templates.

Update

Never use this. It forces you to:

  • have logic dispersed into files which are not required
  • have the same form for creating and editing, unless you disperse things even more with the “solution” outlined here

Use instead the “viable solution” criticised by the above article, how to use forms with data stored in array, so that you put the logic in the controller (where it should be, whatever the Symfony people say) and STOP.

 


Symfony notes – building the Download Portal

Preliminary considerations

We use PHPStorm with PHP 7.2, apache installed, php-xdebug installed in Ubuntu.

Setup

We read the setup guide. A good source probably will be also Symfony 3 For Beginners.

We are on a Ubuntu 18.10 x64 VM, with a Windows 10 professional host. After installing PHPStorm, it seemed everything was set up, but creating a new project with

$ composer create-project symfony/skeleton d1
$ composer create-project symfony/website-skeleton d2

would create empty, or almost empty, projects (obviously non-working).

$ composer require symfony/requirements-checker

did not solve the problem, which was solved installing missing packages:

$ sudo apt install php libapache2-mod-php php-mbstring php-xmlrpc php-soap php-gd php-xml php-cli php-zip php-mysql php-curl

Then everything worked. We start with

$ composer create-project symfony/website-skeleton download_portal

Another thing that will be useful is the command line interface.

$ wget https://get.symfony.com/cli/installer -O - | bash

We install it globally with the adviced command

$ sudo mv /home/filippo/.symfony/bin/symfony /usr/local/bin/symfony

We run also the secuity check with this, as adviced by https://github.com/sensiolabs/security-checker#integration, by running the following from the project base directory.

$ symfony security:check

This will have to be executed periodically (or maybe as part of a final check before release).

Another useful read is the coding standard document.

Enabling Symfony plugin in PHPStorm

Just remember to do it in Settings → Language & Frameworks → PHP → Symfony. You’ll have to restart PHPStorm (manually).

Starting

$ composer bin/console server:start

This starts the server, listening on 127.0.0.1:8000. Navigation to the first page shows the Symfony web debug bar – it will be very useful. The “404” error is expected since there is no route.

We build also a first page for our self-training, following approximately the docs here. The example shows how to route using the file routes.yaml, then it mentions that you can “also” route via annotations. The best practice, according to the Symfony docs (https://symfony.com/doc/master/best_practices/controllers.html), is to route via annotations. So we do it.

The tutorial says we should execute

$ composer require annotations

and we do it. Then we create the file src/Controller/TestController.php. We don’t need the lucky numbers example. It is enough to say “hello world” here.

<?php
// src/Controller/TestController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TestController
{
    /**
    * @Route("/test/test", name="test")
    */
    public function test()
    {
        return new Response(
            '<html><body>Hello world!</body></html>'
        );
    }
}

This works, and also the Symfony web debug bar shows that we are authenticated as an anonymous user.

The name=”test” will be needed later, keep it.

The guide says a little about the installation of packages via composer and the usage of flex, but it seems to be unnecessary to study this now. We instead try to make a template, which will be very useful later. We create a template:

{# templates/test/test.html.twig #}
<body> <h1>{{ salute }} world!</h1>
</body>

and change the controller to:

<?php
// src/Controller/TestController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class TestController extends AbstractController
{
     /**
      * @Route("/test/test")
      */
    public function test()
    {
        return $this->render('test/test.html.twig', [
            'salute' => 'Greetings',
        ]);
    }
}

Further reading

Simply read the routing docs (no need to implement anything, it is very straightforward). Then, the controller docs show things we already know about, and says the important thing that extending AbstractController provides you with the additional methods render (already used to render the template), generateUrl(), redirectToRoute() and redirect(). Fetching Services could be useful. It is also mentioned that you can generate controllers

$ php bin/console make:controller BrandNewController

and generate an entire CRUD from a Doctrine entity (which we’ll find is the ORM).

$ php bin/console make:crud Product

In the controller docs we learn also how to generate 404 errors via exceptions.

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

// generate 404
        throw $this->createNotFoundException('The item does not exist');
// this exception ultimately generates a 500 status error
        throw new \Exception('Something went wrong!');

In the controller we can also access the request object information, by type-hinting an argument with the Request type,  the session by type-hinting an argument with the SessionInterface type:

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;

// ... in the controller
    public function index(SessionInterface $session, Request $request, $firstName, $lastName)
    {
        // access a request parameter
        $page = $request->query->get('page', 1);
        // stores an attribute for reuse during a later user request
        $session->set('foo', 'bar');
        // gets the attribute set by another controller in another request
        $foobar = $session->get('foobar');
        // uses a default value if the attribute doesn't exist
        $filters = $session->get('filters', []);
    }

Sessions can be used to store information about the user between requests. Session is enabled by default, but will only be started if you read or write from it. Session storage and other configuration can be controlled under the framework.session configuration in config/packages/framework.yaml. Stored attributes remain in the session for the remainder of that user’s session. There is a specific documentation.

You can also store special messages, called flash messages, on the user’s session. By design, flash messages are meant to be used exactly once: they vanish from the session automatically as soon as you retrieve them. This feature makes them useful for storing user notifications.

Also there are more tools for responses. Like the Request, the Response object has also a public headers property. This is a ResponseHeaderBag that has some nice methods for getting and setting response headers. The header names are normalized so that using Content-Type is equivalent to content-type or even content_type.

The only requirement for a controller is to return a Response object.

use Symfony\Component\HttpFoundation\Response;

// creates a simple Response with a 200 status code (the default)
$response = new Response('Hello '.$name, Response::HTTP_OK);

// creates a CSS-response with a 200 status code
$response = new Response('<style> ... </style>');
$response->headers->set('Content-Type', 'text/css');

// JSON - returns '{"username":"jane.doe"}' and sets the proper Content-Type header
return $this->json(['username' => 'jane.doe']);

// using the file() helper to serve a file from inside a controller:
// send the file contents and force the browser to download it
return $this->file('/path/to/some_file.pdf');

The file() helper provides some arguments to configure its behavior.

Templates

The documentation about templates starts here. It is possible to use PHP as a templating language (like any PHP page in a website), or it is possible to use Twig. It is similar to the Jinja2 templating system we already use in python – there are {{ … }} (variables, results of expressions), {% … %} (statements such as for loops), {# … #} (comments not included in the generated pages), {{ title|upper }} (filters). It is possible to extend base templates with a block replacement syntax. It is possible to template HTML and CSS. There is a function to generate URLs:

<a href="{{ path('welcome') }}">Home</a>

which refers to the route named ‘welcome’.

Remember that you can link to assets via a specific function, after installing the asset package:

$ composer require symfony/asset

and you can do

<img src="{{ asset('images/logo.png') }}" alt="Symfony!" />
<link href="{{ asset('css/blog.css') }}" rel="stylesheet" />

An advice is given about the way you link stylesheets, java libraries and so on. You define a base template where you insert things that have to be used everywhere, and add things in the extended template, using the parent() Twig function to include both the parent content and the content defined in the derived template.

{# templates/base.html.twig #}
<html>
    <head>
        {# ... #}
        {% block stylesheets %}
            <link href="{{ asset('css/main.css') }}" rel="stylesheet" />
        {% endblock %}
    </head>
    <body>
        {# ... #}
        {% block javascripts %}
            <script src="{{ asset('js/main.js') }}"></script>
        {% endblock %}
    </body>
</html>
{# templates/derived/derived.html.twig #}
{% extends 'base.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link href="{{ asset('css/specific.css') }}" rel="stylesheet" />
{% endblock %}

{# ... #}

Symfony also gives you a global app variable in Twig that can be used to access the current user, the Request and more.

Output escaping is done automatically unless explicitly disabled:

<!-- output escaping is on automatically -->
{{ description }} <!-- I &lt;3 this product -->

<!-- disable output escaping with the raw filter -->
{{ description|raw }} <!-- I <3 this product -->

Configuration

The configuration docs are straightforward. We’ll use YAML, I think. The configuration for each package can be found in config/packages/. For instance, the framework bundle is configured in config/packages/framework.yaml. There is also a .env file which is loaded and its contents become environment variables. This is useful during development, or if setting environment variables is difficult for your deployment. When you install packages, more environment variables are added to this file. But you can also add your own.

Environment variables can be referenced in any other configuration files by using a special syntax. For example, if you install the doctrine package, then you will have an environment variable called DATABASE_URL in your .env file. This is referenced inside config/packages/doctrine.yaml.

Applications created before November 2018 had a slightly different system, involving a .env.dist file. For information about upgrading, see Nov 2018 Changes to .env & How to Update. This may be useful to remember since it means that examples made before 2018-11 can be outdated.

Some useful information we copy directly here.

The .env file is special, because it defines the values that usually change on each server. For example, the database credentials on your local development machine might be different from your workmates. The .env file should contain sensible, non-secret default values for all of your environment variables and should be commited to your repository.

To override these variables with machine-specific or sensitive values, create a .env.local file. This file is not committed to the shared repository and is only stored on your machine. In fact, the .gitignore file that comes with Symfony prevents it from being committed.

You can also create a few other .env files that will be loaded:

  • .env.{environment}: e.g. .env.test will be loaded in the test environment and committed to your repository.
  • .env.{environment}.local: e.g. .env.prod.local will be loaded in the prod environment but will not be committed to your repository.

If you decide to set real environment variables on production, the .env files are still loaded, but your real environment variables will override those values.

To learn more about how to execute and control each environment, see How to Master and Create new Environments.

Database

Our application needs a database. Symfony uses Doctrine as ORM. Installation:

$ composer require symfony/orm-pack
$ composer require symfony/maker-bundle --dev  # this is only an help to generate code

We configure the DB url in the .env file. We use SQLite, at least for now.

We have to do

$ sudo apt-get install php-sqlite3

to be able to use SQLite with PHP. We change

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

to

DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db

Now we run

$ php bin/console doctrine:database:create

creating the sqlite database /home/filippo/repositories/download_portal/var/data.db. Note that the entire /var path is not saved in the git repository, due to the default .gitignore file.

The first entity we create is the Project for our download application. We use

$ php bin/console make:entity

The id property is created automatically, we add descriptor and  human_name. Files src/Entity/Project.php and src/Repository/ProjectRepository.php are created automatically. To create the database we run

$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

The next sections of the documentation are straightforward.

Authentication

The security documentation is the place where to start. We install the security bundle:

$ composer require symfony/security-bundle

We create an User entity.

$ php bin/console make:user

We also keep all the defaults except we use the username as unique user identifier. Files src/Entity/User.php and src/Repository/UserRepository.php are created automatically as well. Then we run again

$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

The “User Provider” and how the passwords are encoded are already configured when using make:user. The file config/packages/security.yaml is changed automatically as well.

We skip the part about the UserFixtures, we’ll return to it later.

Security

The security system is configured in config/packages/security.yaml. The most important section is firewalls. A “firewall” is your authentication system: the configuration below it defines how your users will be able to authenticate (e.g. login form, API token, etc).

# config/packages/security.yaml
security:
    firewalls:
      dev:
        pattern: ^/(_(profiler|wdt)|css|images|js)/
        security: false
      main:
        anonymous: ~

The documentation now tells you about the Guard authenticator. However, in the building of the login forms, the guard authenticator is generated automatically, as we will see.

Looking for how to build the login system

Now we have some conflicting information. The Symfony 4.0 documentation https://symfony.com/doc/4.0/security/form_login_setup.html advice is to use FOSUserBundle. The advice has been removed as of Symfony 4.2, and indeed it seems a deprecated way of doing it (https://github.com/symfony/symfony-docs/issues/9946, https://jolicode.com/blog/do-not-use-fosuserbundle). We follow the 4.2 documentation.

$ php bin/console make:auth

And select “Login form authenticator [1]”, the name ” LoginFormAuthenticator” for the class name of the authenticator, and leave the default “SecurityController” name for the controller class.

After this, we have a login form working at http://127.0.0.1/login.

In src/LoginFormAuthenticator.php, we redirect onAuthenticationSuccess:

return new RedirectResponse($this->urlGenerator->generate('test'));

We need also some dummy users, let’s go back to the security documentation where we are told to use DoctrineFixturesBundle:

$ composer require orm-fixtures --dev
$ php bin/console make:fixtures

with class name UserFixtures. Then modify src/DataFixtures/UserFixtures.php:

<?php
namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use App\Entity\User;

class UserFixtures extends Fixture
{
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public function load(ObjectManager $manager)
    {
        $user = new User();
        $user->setPassword($this->passwordEncoder->encodePassword(
            $user,'test'
        ));
        $user->setUsername('test');
        $manager->persist($user);

        $manager->flush();
    }
}

Create the fixture by running:

$ php bin/console doctrine:fixtures:load

We can then login at http://127.0.0.1:8000/login, being redirected at test (where we can see, in the web debug toolbar, that we are logged in as “test”).

We add a logout function also – in the symfony 4 docs, it is said to configure a route in config/routes.yaml. However we never used this, so it may be better to do as here, just for consistency. However the route option seems easier, so we’ll make an exception for the logout (since we can dispense with the controller).

config/routes.yaml:

app_logout:
  path: /logout
  methods: GET

config/packages/security.yaml: under security: -> firewalls -> main:

 logout:
    path: app_logout
    # where to redirect after logout - we redirect to our test page as of now.
    target: test

Admin

Our application will require an admin interface some time in the future. We investigate a little. A starting point is here. EasyAdmin seems the way to go, users say it is better documented and much easier to use, even if less powerful.

$ composer require admin
We then edit config/packages/easy_admin.yaml:
easy_admin:
    site_name: 'Download portal administration'
    entities:
        Project:
            class: App\Entity\Project
        User:
            class: App\Entity\User

Now we can already manage the entities, but without any authentication. Since we want to test security, we configure it for the admin. We go back to the security document.

We have roles defining what an user can do. When a user logs in, Symfony calls the getRoles() method on your User object to determine which roles this user has. In the User class that we generated earlier, the roles are an array that’s stored in the database (this has been done automatically, if it wasn’t clear), and every user is always given at least one role: ROLE_USER (this not in the DB, but in the User entity class).

We create a very small hierarchy, where there is also ROLE_ADMIN. Only ROLE_ADMIN users will be able to access the admin. The example says:

# config/packages/security.yaml
security:
    # ...
    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

We keep it this way, for now.

We change the UserFixture to add an “admin” and a “superadmin” user:

with class name UserFixtures. Then modify src/DataFixtures/UserFixtures.php:

<?php
namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use App\Entity\User;

class UserFixtures extends Fixture
{
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public function load(ObjectManager $manager)
    {
        $user = new User();
        $user->setUsername('test');
        $user->setPassword($this->passwordEncoder->encodePassword(
            $user,'test'
        ));
        $manager->persist($user);
        $user = new User();
        $user->setUsername('admin');
        $user->setPassword($this->passwordEncoder->encodePassword(
            $user,'admin'
        ));
        $user->setRoles(["ROLE_ADMIN"]);
        $manager->persist($user);

        $user = new User();
        $user->setUsername('super');
        $user->setPassword($this->passwordEncoder->encodePassword(
            $user,'super'
        ));
        $user->setRoles(["ROLE_SUPER_ADMIN"]);
        $manager->persist($user);

        $manager->flush();
    }
}

Then:

$ php bin/console doctrine:fixtures:load

As usual there are more ways to secure a part of the site. You can secure using URL matching, or in the controller, or in the template. In security.yaml:

    access_control:
        # require ROLE_ADMIN for /admin*
        - { path: ^/admin, roles: ROLE_ADMIN }

At this point, it will work – by throwing an “access denied” if the test user tries to access /admin, for example.

FOSUserBundle

The FOSUserBundle package seems ok to provide standard user management features (such as password reset via email). We install it. Executing immediately

$ composer require friendsofsymfony/user-bundle

causes an error. It is stated that “If you encounter installation errors pointing at a lack of configuration parameters, such as The child node “db_driver” at path “fos_user” must be configured, you should complete the configuration in Step 5 first and then re-run this step.” Remember this! The docs say to edit app/config/config.yml, but since we are on Symfony 4.2, we have to edit config/packages/fos_user.yaml instead:

# config/packages/fos_user.yaml
fos_user:
    db_driver: orm # other valid values are 'mongodb' and 'couchdb'
    firewall_name: main
    user_class: App\Entity\User
    from_email:
        address: "ndp@ndp.test" # Provide a standard email address that we will have to change later - maybe a configuration in the DB customizable by the admin?
        sender_name: "ndp@ndp.test"

Note that the example configuration uses from_email: address: "%mailer_user%" which does not exist in our installation, so it would cause an error anyway. We’ll configure this later.  Repeating $ composer require friendsofsymfony/user-bundle will still fail with “The service “fos_user.resetting.controller” has a dependency on a non-existent service “templating”. “. Someone says to solve by adding php to the templating engines. This is not the solution, the solution is instead to add to config/packages/framework.yaml:

    # Added for FOSUserBundle
    templating:
      engines: ['twig']

At this point $ composer require friendsofsymfony/user-bundle works. Since we already have an User class, we have to change it in order to extend it from FOS\UserBundle\Model\User. In src/Entity/User.php:

<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use FOS\UserBundle\Model\User as BaseUser;
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User extends BaseUser
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    protected $id;
}

everything else is provided by the FOSUser bundle.

We have to clear manually the user DB table (otherwise the migration won’t work), make a migration and run it

$ bin/console make:migration
$ bin/console doctrine:migrations:migrate

We have to change also the user fixtures /src/DataFixtures/UserFixtures.php, adding the email and enabling the accounts (otherwise we won’t be able to login due to “disabled” accounts) – for example:

$user->setEmail('test@ndp.ndp');
$user->setEnabled(true);

 

From now, we are leaving the “novice” realm and we continue without writing everything.


A self-extracting tar archive

If you want to create a self-extracting archive, or a self-contained executable, you may create a shell script which contains packed data, and can extract those data automatically. You proceed as follows.

Creating the archive

The archive is created with name $TGZ_NAME, containing the files $FILES_TO_PACK

$ tar czf $TGZ_NAME $FILES_TO_PACK
Creating the installer script

The installer script is a normal bash script, which can access the packed data in a special way.

BUILTIN_PACKAGE_SEARCH_RES=$(grep -hnam1 '^TGZFILE_DATA:[^:]\+:$' $0)
if [ $? -eq 0 ]; then
    BUILTIN_PACKAGE_LINE=$(echo "$BUILTIN_PACKAGE_SEARCH_RES" | cut -d ':' -f 1)
    BUILTIN_PACKAGE_NAME=$(echo "$BUILTIN_PACKAGE_SEARCH_RES" | cut -d ':' -f 3)
else
    # error
fi

We have also recovered the original package name, which can contain useful information.

The package is then unpacked with:

START_LINE=$(($BUILTIN_PACKAGE_LINE + 1))
tail -n +$START_LINE $0| tar xzvf - -C "$DEST_DIR" >> "$LOG_FILE_NAME" 2>&1

Where $DEST_DIR is a destination directory (existing – it may be a temporary directory or something else) and $LOG_FILE_NAME is a log file for the result of the unpacking. We can avoid it with:

tail -n +$START_LINE $0| tar xzvf - -C "$DEST_DIR" >> "/dev/null" 2>&1

At the end you may also self-destruct the script with:

rm $0
Packaging everything

Assume that the installer script is called script.sh – any name is ok since it won’t appear in the final product.

$SH_NAME is the name of the resulting package.

$ cat script.sh > $SH_NAME$ cat "TGZFILE_DATA:$TGZ_NAME:" >> $SH_NAME$ cat $TGZ_NAME >> $SH_NAME
$ chmod +x $SH_NAME

At the end, $SH_NAME will be an executable script, able to access the packaged data.


Tools, frameworks, libraries

Testing

SeleniumHQ

SeleniumHQ is a Python framework for functional testing of web apps. Controlling the browser may be difficult, sometimes.

Behave

A Python framework for improving functional testing with user-friendly language Gerkhin (which is also much more compact). Install with pip install behave.

Carosello

Python library developed by Andrea Parisi (MiBit) for helping with testing.

Web frontend

A reasonable stack can be: HTML5 + CSS + Bootstrap, React.js for the controls, Redux.js for managing the interaction (controller). The elm language which provides strong types and compiles to javascript.

Presentation

A Javascript presentation engine is Remark. A presentation can be specified by a very simple text (markdown) files.

Prototyping

Figma is an interface prototyping tool, available online, free for 1-user with read-only share.

Charts

yEd is a chart editor available for Windows, MacOS, Linux.

 


Qt image size in push buttons and labels

Often we want to show an image in a push button (QPushButton) or in a window (using QLabel). The automatic resizing offered by the Qt framework isn’t always working, so I use the following trick.

The following global functions are defined:

#include <QApplication>

double getLogicalDPI(void)
{
    return QApplication::screens().at(0)->logicalDotsPerInch();
}

double get_resolution_ratio_vs_mdpi(void)
{
    const double mdpi_DPI=80.; // really this is a "logical" DPI resolution - accounting for some scaling by Qt, the OS and so on. Use this.
    static double resolution_ratio=
        getLogicalDPI()/mdpi_DPI;
    return resolution_ratio;
}

In  any form constructor, we do the following:

{
    ...
    ui->setupUi(this);
    ...
    // Fix icon sizes for high-DPI displays
    double scale_factor=get_resolution_ratio_vs_mdpi();
    // for icons in push buttons:
    ui->acceptButton->setIconSize(ui->acceptButton->iconSize()*scale_factor);
    // for icons in labels:
    ui->label_chat_icon->setFixedSize(ui->label_chat_icon->minimumSize()*scale_factor);
    ...
}

In any .ui file, we do the following. For a push button, we set the iconSize property to the size we would like at mdpi (normal resolution). For a QLabel, we set sizePolicy to Fixed, then minimumSize = maximumSize = the size we would like at mdpi (normal resolution).


Generating an ODF document in PHP

Sometimes, you have to generate some kind of document, a commercial offer for example, based on some data that has been entered into some HTML form, and you want it to remain editable, so a PDF document is not the solution. You may consider generating an HTML document, but you quickly realize that you want something that is easily editable by your HTML-illitterate co-worker. Using OpenDocument would be an excellent choice.

Since you know that an ODF file is “just zipped xml”, you may think that creating the file from scratch is the way to go. A better way, if the document has any complexity in it, is to start from some template, and with a few instructions change it into your final document.

A quick search reveals a few tools that can be used in this way. The one is suiting me best now is OpenTBS, which is a plugin for the “TinyButStrong” PHP template engine. It seems easy to use: You set a few “anchors” in your template, a few variables in your PHP code, and it works. It is flexible enough that you can change values in the formulae inside table cells, add rows to tables, link pictures and so on. The TBS template engine is just one PHP file, and OpenTBS another PHP file. It can’t really get any simpler than this.

As a side note, I wonder if there is any WordPress plugin to export blog entries as an ODF document. Could it be useful to convert an entire blog to a still-editable book?