Skip to content

Paint a layout as vectors: paint.PaintVector, PageLayout, pdfcanvas - #297

Merged
tannevaled merged 1 commit into
mainfrom
feat/vector-paint
Oct 19, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/vector-paint

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What

A vector backend for the paint layer, and the API to drive it page by page:

  • paint.Canvas — a small vector surface (Save/Restore, Clip(path, evenOdd), Fill(path, colour, evenOdd), Shade(Shading), Image(img, rect), Glyphs(*GlyphRun)), with Path (lines, cubics, RoundRect, Ellipse).
  • paint.PaintVector(c, box, fonts, imgs, bgImgs, page) VectorStats — walks the box tree in paintBoxContent's order with its geometry and clipping, emitting paths, shadings, images and glyph runs.
  • engine.(*Engine).LayoutHTML / LayoutDocument → *PageLayout with Paint() (the image RenderHTML returns, byte-identical — tested), PaintVector(c), Links(), Info(), Page().
  • pdfcanvas — the paint.Canvas for a go-pdfkit page (needs go-pdfkit v0.15.0: ShowGlyphs, NewFont, radial shadings).
  • css.GradientSampler.Geometry() — the resolved axis/ellipse and stops, so a backend can emit a shading instead of sampling.

Design note — why this shape

Read first. paint.go paints straight into an *image.RGBA (go-widgets/painter for rects, coverage masks for glyphs, per-pixel loops for gradients/shadows/tiles); there is no display list or painter interface. Two options:

  1. Refactor paint.go behind an interface so raster and vector share one walker. It touches ~1,800 lines that other sessions edit daily, and every raster primitive (per-pixel erf shadows, group buffers, filter chains) would need a vector twin or an escape hatch inside the abstraction — a large, risky diff for the raster path, which must stay byte-identical.
  2. A second walker over the same layout.Box tree (chosen). Layout is shared — positions, line breaks, fonts and image sizes come from the one layout pass — so the vector page has the raster page's layout by construction; only painting is duplicated, and it mirrors paintBoxContent step for step (shadows, background, layers, inset shadows, borders, overflow/clip clipping, marker, inline fragments, items, children). The raster path is untouched.

What is vector: backgrounds and borders (rounded: a ring between two rounded rects, even-odd), sharp box-shadows (clip-excluded / even-odd), linear and radial gradients (type 2 / type 3 shadings; a CSS gradient is split into runs of constant opacity — the uncover theme's hard stop to transparent is vector), text as positioned glyphs of the faces the layout measured with (exact advances, as Fonts.Measure; fallback-face runs; rlig ligatures carry their whole text for /ToUnicode), <img> and background images placed in their boxes (the source bitmap, so identical images dedupe across pages), list markers, underlines, overflow and clip: rect() clipping.

Per-element raster fallback for what has no vector form here: a subtree under opacity < 1, filter, mask-image, transform: rotate() or backdrop-filter; a blurred box-shadow; a gradient whose opacity fades between stops; a form control; a background image tiled > 1024 times. The raster painter paints just that piece into a page-sized scratch buffer (bounded: 64 Mpx), cropped to its ink and placed as an image. The text of a rasterised subtree is laid over its bitmap invisibly (text render mode 3), so it stays selectable. VectorStats counts it all.

Alternative considered: go-pdfkit/html2pdf already drives the layout tree into pdfkit, but it is a paginating document exporter with a reduced painter (no radius, gradients, shadows, @font-face, opacity); a full-fidelity per-page painter belongs next to the raster painter it mirrors, and html2pdf can adopt PaintVector later.

Bibliography (opened)

ISO 32000-1:2008 (Adobe's PDF32000_2008.pdf): §8.5.4 Clipping Path Operators (W/W* n), §8.7.4.5.3 Type 2 (axial) and §8.7.4.5.4 Type 3 (radial) shadings, §9.3.6 Text Rendering Mode (mode 3), §9.4.3 Text-Showing Operators (TJ), §9.10.3 ToUnicode CMaps, §11.6.4.4 Constant Shape and Opacity, §11.6.5.3 Soft-Mask Images. Engine: paint/paint.go, paint/fonts.go, css/gradient_sample.go, engine.go (renderCore, newCanvas), linkmap.go.

Verification

  • paint/vector_test.go has a reference rasteriser for the Canvas (point-sampled winding, shadings, images, glyph masks) and checks pixel parity against PaintFull on every feature (backgrounds, borders, rounded rings, 13 gradient shapes, background tiles, 8 shadow kinds, markers, images with object-fit and filters, groups, overflow and clip): ≤ 2–4 % of pixels off by > 64 (antialiasing and sub-pixel glyph positions). paint coverage stays 100 %, css 99.5 %.
  • pdfcanvas tests read the PDF back with rsc.io/pdf: Type0 fonts with /ToUnicode (é included), axial and radial shadings, XObjects, 3 Tr for the ghost text.
  • In go-slides (poppler pdftoppm at 96 dpi vs the raster render, per slide): 100-slide deck 0.46 % mean pixels off (worst slide 1.2 %), text extracted by pdftotext; 758 KiB against 10 MiB raster and marp-cli's 837 KiB.

MINOR: new API → next minor tag after merge.

🤖 Generated with Claude Code

paint.Canvas is a small vector surface (clip, fill, shade, image, glyph
runs); paint.PaintVector walks the box tree in paintBoxContent's order and
geometry and draws through it: backgrounds, borders and sharp shadows as
paths, linear and radial gradients as shadings split into runs of constant
opacity, text as positioned glyphs of the faces the layout measured with,
images placed in their boxes. What has no vector form (opacity < 1, filter,
mask-image, rotation, blurred shadows, fading gradients, form controls) is
painted by the raster painter as a bitmap of that element only, its text
laid over it invisibly so that it stays selectable.

Engine.LayoutHTML/LayoutDocument return a PageLayout that can be painted
as an image (identical to RenderHTML) or as vectors, so pages can be laid
out concurrently and painted in order. pdfcanvas is the Canvas for a
go-pdfkit page (v0.15.0: ShowGlyphs, NewFont, radial shadings).
css.GradientSampler.Geometry exposes the resolved axis/ellipse and stops.

The raster path is untouched. paint keeps 100 % coverage; the vector tests
compare a reference rasterisation of the Canvas calls with PaintFull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit d964844 into main Oct 19, 2026
7 checks passed
@tannevaled
tannevaled deleted the feat/vector-paint branch October 19, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant