Using a single TXR Lisp expression, based on a pipeline of higher order functions and partial application, and a quasiliteral string for formatting the fixed-width fields:
$ txr -e '[(opip (partition* @1 (op where (op equal ""))) (tuples 3) (reduce-left (op mapcar append)) (apply mapdo (op pprinl `@{1 6} @{2 6} @{3 6}`))) (get-lines)]' < data aaa 1 1.1 bbb 2 1.2 ccc 3 1.3 ddd 4 1.4 eee 5 1.5 fff 6 1.6 ggg 7 2.1 hhh 8 2.2 iii 9 2.3 jjj 10 2.4 kkk 11 2.5 lll 12 2.6
How it works
Overall the whole expression has the form [function argument]. The argument is (get-lines), which snarfs lines from a stream and returns a (lazy) list of strings. The stream defaults to *stdin*. The function is constructed by the (opip ...) macro, and that's where all the action happens.
To understand opip, we have to know op, which opip uses implicitly: it stands for "op pipeline"). Also, op is used explicitly in a few places. In a nutshell, (op function args ...) is a syntactic sugar for creating an anonymous function which calls function, and cooks some of the arguments. Within args ..., the anonymous function's arguments can be referenced by number. The anonymous function also implicitly takes trailing arguments. For instance (op + 3) denotes an anonymous function which adds its arguments together, and adds 3. (op - @1 3) is an anonymous function which subtracts 3 from its argument. The syntax @1 denotes the insertion of the functions first argument into the given position in the expression. (op mapcar append) is a function to which we can pass a bunch of lists, each of which contains lists. The function will take these lists tuple-wise and append them together. This is the basis for the paste-like logic for joining the data.
The opip macro takes a bunch of expressions and essentially inserts op into them, and then creates a function which pipes data through the resulting anonymous functions. That's a simplification, but it will do.
(partition* @1 (op where (op equal ""))) breaks up the list of raw lines from the file into partitions based on cutting the list where its elements are blank lines (equal to the empty string), and removing those entries. (The partition function without the * in its name will leave those blanks in place).
(tuples 3) gathers up these partitions into groups of 3.
These groups of 3, or triplets, have to be accumulated together in parallel: the first elements of the triplets have to be appended into a single list, the second elements into a single list and so on. That is the job of (reduce-left (op mapcar append)). The kernel function (op mapcar append) is given a pair of triplets, and catenates their corresponding entries together to create a merged triplet. The reduce-left function decimates the list of triplets through this down to a single triplet.
This master triplet is then applied as arguments to a mapdo call, in the final expression (apply mapdo (op pprinl ...)). mapdo receives a function as its leftmost argument, generated by (op ...) once again. The remaining arguments are the three elements of the giant triplet, representing the three columns of data. The columns are mapped row by row through the anonymous function.
The anonymous function takes three arguments which are referenced in the quasiliteral string `@{1 6} @{2 6} @{3 6}`, where @{1 6} means @1, set in a field width of 6. This string quasiliteral which interpolates the three arguments (three elements pulled pairwise by mapdo from the triplet of columns) constructs a string which the anonymous function passes to pprinl, which prints it with a newline.