Takumi

The paint tree

Read what the renderer paints with takumi-paint, for PPTX, Canvas, and native drawing exporters.

takumi-paint lays out a document and returns what painting it would draw, instead of an image. Use it when the output is not pixels: an editable PPTX, a Canvas replay, a native drawing API.

Every CSS value arrives resolved. You draw shapes, paints, text, and images; you never parse CSS.

Read the boxes and runs
import {  } from "takumi-paint";

const  = await (
  `<style>.big { font: 700 40px Geist; color: #B3261E }</style>
   <div id="card" style="width: 640px; padding: 32px; background: #F7F3EC; border-radius: 12px">
     <div class="big">4,2 %</div>
     <p>hello <b>world</b></p>
   </div>`,
  { : 640 },
);

const  = .("card");
?.[0]; // { type: "fill", role: "background", shape: { type: "rounded-rect", … }, paint: { type: "color", color: [247, 243, 236, 255] } }

for (const  of ) {
  if (. !== "text") continue;
  for (const  of .) {
    .(., .?., .., .., .);
  }
}

The package runs the same layout as takumi-js and walks the same stacking-context scene the image, SVG, and PDF backends paint.

Two views of one document

Iterating the document, or a box, yields nodes in document order. find(id) returns the box of the element with that id. Use this view to rebuild editable objects: node.parent and box.children follow the input tree, and a TextRun names the element it came from.

paintSteps() yields the steps a renderer takes, in paint order. Use this view to draw:

Replay the steps
import type { ,  } from "takumi-paint";

declare function (: , : ): void;
declare function (): ;

function (: , : ) {
  const : [] = [];
  let  = ;

  for (const  of .()) {
    switch (.) {
      case "draw":
        .(.....);
        for (const  of .) (, );
        break;
      case "begin-clip":
        .();
        .(.....);
        // Clip to step.clip.
        break;
      case "end-clip":
        .();
        break;
      case "begin-group":
        .();
         = ();
        break;
      case "end-group": {
        const  = .() ?? ;
        // Filter, clip, and mask the layer per step.node.effects, then blend it into parent.
        .();
        . = ..?. ?? 1;
        .(., 0, 0);
        . = 1;
         = ;
        break;
      }
    }
  }
}

z-index, negative stacking, and outlines that paint after later siblings are already in the step order.

Coordinates

Every length is a device pixel, and so are the width and height options. A 20px font measures 40 under a devicePixelRatio of 2.

A node's transform maps its local space onto the canvas. Its drawables sit in that local space:

  • A box's local space is its border box.
  • A text node shares its box's space. A run's x and y are the start of its baseline.
  • An image node's local space is its content box.

The vocabulary

ConceptVariants
Shaperect, rounded-rect, path (SVG path data, as new Path2D() takes it)
Paintcolor, linear-gradient, radial-gradient, conic-gradient, image, pattern
Strokewidth, dash, cap, join
Drawablefill, stroke, shadow, glyphs, image
Filterblur, color-matrix, drop-shadow, unsupported
Effectsopacity, blend mode, isolation, filters, backdrop filters, clip, mask

Gradient stops interpolate in sRGB. Takumi adds stops where the CSS interpolation space, such as the default Oklab, would differ, and unrolls repeating gradients over the area they cover. Background and mask layers arrive as pattern paints: one tile drawn at every x and y.

A shadow drawable is a blurred copy of its shape, moved by offset, and visible only outside or inside its box. blur is the Gaussian's standard deviation, half of a CSS blur radius.

Every drawable carries a role, such as background, border, box-shadow, outline, text, or text-decoration, so an exporter can put a box's background, border, and shadow back into one shape.

Text

A TextRun holds the final text, its font, and its glyph ids and positions. run.outline() returns the glyphs as SVG path data from the baseline start, so a consumer can draw the text without a font engine. run.font.data() returns the font file for a native text API.

A glyphs drawable fills a run's outline with a paint in the node's space: the text color, a text shadow's color with a blur, or the box's background for background-clip: text. With stroke, it strokes the outline instead, as -webkit-text-stroke does.

What stays unresolved

  • filter: url(#id) arrives as { type: "unsupported", css }.
  • backdrop-filter needs what is already drawn behind the node, which targets such as PPTX cannot read.
  • A translucent layer inside a box, such as a span's opacity, is multiplied into each draw, so overlapping draws in it paint darker than the renderer's layer.
  • Bitmap glyphs, such as some emoji fonts, are left out.

Last updated on

On this page