Releases: r-vellum/vellum
Release list
vellum 0.6.8
Linting
-
Fix:
invisible_fillandbleedfired on every plot a grammar layer
produces. Found by running the new rules against vellumplot's output, where
each of five ordinary plots collected one of each with no true positives among
them.invisible_fillnow exempts a mark that spans its whole viewport. Something
filling its entire container is a substrate rather than a mark — a theme's page
background, a panel backdrop — and painting the page in the page's own colour
is how you guarantee an opaque export, not an oversight. A mark that merely
sits inside the viewport is still reported.bleednow exempts text, which is most of what the rule was reporting. A label
is routinely placed in a strip sized from an approximate metric and overhangs
it by a few pixels; measured across that corpus, every escape was an axis or
plot title clearing its strip by 4–8 px and none was a mark. A non-text mark
escaping an unclipped viewport is still reported.
The linter grew from 7 rules to 20, and from something you read to something you
can gate a build on. vignette("inspecting-scenes") covers all of it.
-
Thirteen new rules. The one worth having first is
truncated:offscreen
andclipped_awayboth require a mark to be entirely gone, so the defect
that actually ships — the axis label with its last characters cut off, the
title chopped by the page edge — had no rule at all. It reports how much was
lost, as a warning for text and a note otherwise.Then, in rough order of how hard they are to catch by eye:
cvd_collision— two colours a reader is meant to tell apart that a
colour-blind reader cannot. Nobody catches this by looking, because the
person looking can see the difference.font_fallback— a character no font on this machine can draw, which
renders as a tofu box. Deliberately machine-dependent, which makes it the
rule most worth running on CI rather than on the author's laptop.invisible_fill— a mark filled in the page's own background colour with no
outline. Painted, correctly sized, correctly placed, and invisible.occluded— an opaque mark completely hidden behind a later one: ink that
never reaches the page, usually a layer ordering mistake.label_on_mark— a label swallowing the mark it annotates, which is exactly
whatvl_repel()exists to fix.overplotted— a batched mark dense enough to hide its own distribution,
measured per layer so it names the one to fix, and suggestingdatashade().hairline— a stroke under half a pixel, which the raster backends render as
a dpi-dependent smudge and the vector backends as a crisp line.subpixel— an area mark less than a pixel across.bleed— a mark drawn outside a viewport that does not clip.duplicate_name— two nodes sharing a name, which silently makes all but the
first unaddressable byget_node(),edit_node()andvl_repel().double_draw— the same mark drawn twice in the same place.blank_label— a text mark with no visible characters.
Every rule was checked against realistic figures as well as planted defects,
and several were deliberately narrowed as a result: a bar chart with value
labels, a panel with axis text, a scatter grazing its clip and a viewport-filling
background rect all lint clean, and there are tests to keep it that way. The
same applies tocvd_collision's threshold, which is calibrated to report
ggplot2's default red/green pair while staying quiet on the CVD-safe Okabe-Ito
and viridis palettes. -
tiny_textgained a point floor and fires on either.font_pxscales with
dpi, so the pixel floor alone stopped seeing illegible text on a
print-resolution render: 4 pt atdpi = 300is 16.7 device px and cleared the
7 px default comfortably.min_text_pt(default6) asks the other question —
can a human read this. Text between 6 pt and the pixel floor may now be
flagged where it was not before; pass both arguments to move both floors. -
vl_lint_assert()turns a lint into a gate for a test suite or a CI job,
without vellum taking a testthat dependency.severity = "note"fails on
anything at all;on = "warn"reports without stopping. -
vl_lint_overlay()draws the findings onto the scene — a box round each
one, labelled with the rules that fired, red for warnings and orange for
notes. For a graphics linter this is usually the faster way to read a report: a
message says a mark is clipped, an outline shows you which. -
vl_lint(exclude = )suppresses findings for named nodes. Suppression is
by node, sincerulesalready selects rules, and the usual case is one
deliberate oddity in an otherwise clean figure — without it a project with a
single intentional off-canvas mark could never reach a clean lint to assert on.
An entry matching nothing warns, because a stale exclude list looks exactly
like a working one.vl_lint(severity = )likewise overrides a rule's own
severity for a project that cares about it more, or less, than vellum does. -
Findings carry the node's device-px box, so a caller can point at a defect
rather than only describe it — which is what the overlay is built on. A rule
reporting something with no geometry getsNA. -
A failing rule is reported instead of aborting the lint. The registry is
open to downstream packages, and one broken rule used to lose every other
rule's findings behind an opaque
Error in get(id, envir = .lint_rules)$fn(...). Failures come back as a
rule_errorfinding naming the rule, as does a rule returning a data frame
without the required columns. -
Rules can see more, and describe themselves.
ctxgainedelements()—
the per-element table, the only honest view of a batched mark, since a scatter
is one node whose box is the union over every point — and
region(x0, y0, x1, y1)for a whole block of composited pixels rather than
probing point by point. Both are lazy, likepixel(). The node table gained
the resolvedfillandfill_kind,lwd_px, the viewport id and extent, and
anotdefcount.vl_lint_rule()takeskinds,needs_pixelsandtags: a
rule naming the kinds it reads is skipped on a scene with none of them, and
vl_lint_rules()reports all three. -
Fix: the lint node table reported light colours wrongly.
colwas packed
as0xRRGGBBAAinto a signed 32-bit integer, which overflows as soon as red
reaches 128 —#EEEEEEcame back as-286331137and unpacked to a red channel
of-18.low_contrasttherefore mis-measured the luminance of every light or
reddish text colour, landing on the right side of its threshold by luck rather
than by arithmetic. Colours are now packed as doubles, which hold all 32 bits
exactly. Reported contrast ratios change accordingly: the README's#F2F2F2
watermark on white is 1.1:1, not the 1.4:1 previously claimed. -
invisiblealso catches a fill that is present but fully transparent.
fill = "#FF000000"sets a colour and then asks for none of it;has_fill
cannot see that, and the fill's alpha channel can. -
label_overlapno longer compares every pair of labels in interpreted R.
The collision search moved to a sort-and-sweep in Rust, which drops a
quadratic term: the detection itself goes from 3.02 s to 0.007 s on 2400
labels, and a fullvl_lint()of that scene from 7.78 s to 2.14 s. At a few
hundred labels the win is smaller, because compiling the scene dominates
either way. Results are unchanged, and tests assert the sweep agrees with the
all-pairs check it replaced.occluded,label_on_markanddouble_drawwere
measured and left alone — their inner loops are already vectorised, and they
cost milliseconds on 400 marks.
Other changes
-
Fix: knitting a scene with a
dpichunk option errored. A YAMLdpi: 150
parses as an<integer>, and the display path passed it straight to the
scene's@dpiproperty, which is declared<double>- S7 rejected it with
@dpi must be <double>, not <integer>. The chunk dpi is now coerced. -
Internal: the sources are clean under the
jarllinter. Dropped a dead
.diff_body()helper, hoisted assignments out offor()heads and out of
on.exit()(via a new.set_debug_reg()setter), added the leading zero to
fractional literals in the vignettes, and removed the redundantvellum:::
prefix from the test suite, which already evaluates inside the package
namespace. The:::calls that reach Rust kernels from the vignettes and
inst/scripts are genuinely required and carry a# jarl-ignorewith a
reason. No user-visible behaviour changes.
vellum 0.6.7
-
Fix: an animated SVG showed one frame and then near-nothing. Every frame is
rendered separately, so each restarted its<defs>id counters and the whole
document ended up with N copies ofid="c0". All Nclip-path="url(#c0)"
references therefore resolved to the first frame's clip — which lives inside
avisibility:hiddengroup, and a hidden<clipPath>child contributes no
geometry, so the clip was empty for the 47/48 of the cycle when frame 0 was
hidden. Everything inside the panel was clipped away, leaving only the axis
labels and legend drawn outside it, and the plot appeared to blink once and
then vanish. Generated<defs>ids now carry a per-frame prefix. -
Fix: animated SVG frames played in reverse. The per-frame negative
animation-delaywas offset byirather thann - i, so after frame 0 the
cycle ran backwards (0, n-1, n-2, …). -
Fix: a gradient (or pattern)
colcollapsed to its first stop on circle
outlines. Circles take a batched fast path that draws a unit circle placed
by an affine transform; a gradient stroke is resolved in viewport pixels, so
on the unit circle it sampled a single point of the ramp and rendered one flat
colour. The same gradient stroked rects and polylines correctly. A gradient/
pattern stroke now drops circles to the per-element, real-coordinate build (as
rects already did), so the ramp runs along the outline. Solid strokes keep the
fast path and render byte-identically. -
svg_grob()can size an icon to itsviewBox, and read a whole<svg>.
Icon sets share oneviewBoxper family and pad each glyph inside it, so
sizing to the glyph's own ink blew every icon up tosizeindividually — a
sparse glyph rendered far larger than a dense one from the same set, and a lone
icon larger than its nominal box (the reported ~2.5× oversize).svg_grob()
now takes aviewboxargument (c(xmin, ymin, width, height), or the raw
"0 0 24 24"attribute string) and maps the box tosize, so glyphs keep
their intended relative and absolute size. Passing a whole<svg>…</svg>
element asdreads its<path>geometry andviewBoxautomatically (needs
\pkg{xml2}; non-<path>shapes are reported, not silently dropped). With no
viewBox the previous ink-bounds sizing is unchanged.
vellum 0.6.6
-
Fix:
element_geometry()andvl_nearest()reported viewport-local
coordinates, not device pixels. The pick table resolved every vertex through
vp.x_pos()/y_pos()— which answer in the viewport's own frame — and never
applied the viewport's transform. So for any viewport not at the page origin,
which is every panel of every real plot:element_geometry()was off by the viewport's offset, and internally
inconsistent:textandroundrectrows went throughnode_bbox()and
came back in device px, whilesegment/line/polygon/path/
point/rectrows stayed local. One table, two coordinate systems.vl_nearest()compared a device-px probe point against those local
coordinates, so its answers in an offset panel were not merely imprecise but
wrong — it would report a mark at distance 0 with the cursor nowhere near it,
and rank the mark actually under the cursor far down the list.
Both now report device pixels with y down, matching
scene_model()'s boxes and
the rendered SVG, as documented. A round mark's radius takes the transform's
scale as well as its translation.This is the same bug
lint_table()had before 0.6.1, and it survived for the
same reason: every test intest-pick.Rdrew into the default full-page
viewport, where the transform is the identity and local is device. The
regression tests added here use an off-origin viewport.Nothing rendered changes — the pick table feeds no drawing path.
-
Docs fix: the leader-line example placed labels and drew leaders from two
different solves.vignette("placement")andinst/examples/labels.Rboth
computedvl_place(scene), drew leaders from that answer, and then called
vl_repel()on the scene with the leaders already in it. The leaders are
obstacles to that second solve — and since each one lies exactly along the path
its label wanted to take, andvl_place()works on bounding boxes (so a
diagonal segment blocks the whole rectangle spanning it), the labels were
pushed back the way they came. The median leader ended up 146° from the
label's real displacement, with half of them pointing essentially backwards.Both now place first and annotate afterwards, which makes the leaders exact,
and both say why. No engine change —vl_repel()was applying the solve it
reported all along. The general rule, now stated in the article: anything you
add to a scene becomes an obstacle to a later solve.
vellum 0.6.5
Two accessibility fixes, both found while wiring vellumplot's accessibility pass.
vl_lint()'s contrast rule no longer cries wolf
It sampled the backdrop at four points just outside a label's box and took the worst of them. So any label whose 2 px probe happened to graze a nearby tick, gridline or axis rule read as roughly 1:1 contrast — which meant dark axis text on a white panel was flagged on essentially every plot.
It now takes the second-worst of the four sides. Incidental adjacent ink on one side is ignored, while a label genuinely sitting on a low-contrast field (all four sides) or straddling a dark region (two sides) is still caught.
A linter that fires on every plot is a linter people switch off, so this is the difference between the rule being useful and being noise.
Gridlines are decorative in tagged PDF
A node with role = "grid" — the role a downstream sets so an interactive SVG host can find and hide the gridlines — was becoming a bogus Figure in the PDF structure tree. It now joins "presentation", "none" and "decorative" as a PDF artifact, skipped by assistive technology.
The SVG role is unchanged, so any host selector relying on it still works. This matters for the same reason the artifact handling did in 0.6.0: a screen reader announcing every gridline is worse than one announcing none.
Note
Third release running whose bug came from using the engine from vellumplot rather than testing it in isolation. The accessibility arc in particular only shows its rough edges when the four pieces — CVD simulation, linting, hatching, tagged PDF — are wired up together on a real figure.
vellum 0.6.4
One bug, and the same shape as 0.6.3: a capability that worked when drawn and not when measured.
grobwidth()/grobheight() ignored a text grob's wrapping width
The renderer wraps a text_grob(width = ) to its box and auto-fits when fit = is set. The measurement path — what a grobwidth/grobheight unit resolves to — measured the unwrapped line.
So a layout track sized by grobheight() reserved a single line for a label that drew as several, and the wrapped text overran whatever sat above it. Exactly the failure you would hit sizing a title block or a caption gutter.
Extent now wraps the label to width, stacks the resulting lines, and applies the same auto-fit shrink as drawing, so a reserved region matches the glyphs that land in it. It mirrors the draw path rather than reimplementing the layout, which is what keeps the two from drifting apart again.
Plain labels only. Rich md() labels are unchanged, because the renderer does not width-wrap them either — measuring a wrap that never happens would be the same bug pointing the other way.
Rendering is unaffected; all 44 regression artifacts are byte-identical. This changes measurement.
Note
Like 0.6.3, this was found by using the engine from vellumplot — here while wiring wrapped plot titles, subtitles and captions — rather than by testing it in isolation. Two releases in a row from the same source, which is a reasonable argument for the downstream integration pass continuing to run ahead of new engine work.
vellum 0.6.3
One bug, in two halves, found while wiring keyed data-label backgrounds in vellumplot.
A keyed roundrect_grob() was broken twice over
roundrect_grob() is a batch — one rounded box per (x, y, width, height, r) element — but it was treated as a single shape on both sides of the seam:
- It recycled
key/metato length 1, soroundrect_grob(key = c("a", "b"))silently kept only"a". scene_model()classed roundrect as a single shape (one row per grob), so a grob drawing N keyed boxes reported one element against the backend's N and aborted on the element-count check.
Roundrect is now a keyed batch, like text_grob(): each keyed box is one scene_model() row carrying its own key and meta, and an unkeyed roundrect stays out of the model entirely.
Rendering is unaffected — scene_model() is interactivity metadata only.
Note
This is the first bug in this series found by using the engine from vellumplot rather than by testing it in isolation — which is exactly what the downstream integration pass was expected to surface.
vellum 0.6.2
One bug, found by looking at a picture.
vl_contour() transposed its input matrix
It assumed rows indexed y and columns x. Base R is the opposite: image(), contour(), persp() and contourLines() all take dim(z) == c(length(x), length(y)), so rows index x — and that is also the shape outer(xs, ys, f) produces, which is how a grid is normally built.
So every contour came back reflected across the diagonal. The documentation compounded it by citing image() as the authority for the opposite of what image() does.
Verified against base R on an asymmetric bump at (x = 2, y = −1):
| result | |
|---|---|
vl_contour(z) before |
(−1, 2) |
grDevices::contourLines(x, y, z) |
(2, −1) |
vl_contour(z) now |
(2, −1) |
Is this breaking?
Only if you were transposing your matrix to work around it. Code that passed outer(xs, ys, f) straight in was getting transposed output and is now correct with no change — which is why no example or vignette needed a code edit, only clearer argument names.
Why 81 contour tests missed it
Every contour test used a surface symmetric in its two arguments, where a transpose is a no-op. On a symmetric Gaussian the bug is invisible; over a density with a reference layer beneath it, it is obvious at a glance.
The new tests use an asymmetric surface and assert against grDevices::contourLines() rather than against our own expectation. That is the same blind spot as the offset-viewport bug fixed in 0.6.1 — in both cases a convenient, symmetric fixture concealed a real error, and a deliberately awkward one exposed it immediately.
Also removes a transpose from the hot path, so it is marginally faster.
vellum 0.6.1
A one-bug patch, and a bug worth explaining.
Node bounding boxes were viewport-local, not device coordinates
lint_table() resolved a node's box through its viewport's scales but never applied the viewport's own placement. element_table() did. So for any viewport not at the page origin the two disagreed by exactly the viewport's offset — which is every real plot with a panel.
Everything built on it inherited the error:
vl_repel()mixed viewport-local label boxes with device-space obstacle boxes, so the solver believed the markers were somewhere they were not. On a panelled scatter it left all 22 labels sitting on top of the very markers it had been asked to avoid. Now 0.vl_lint()'soffscreenandlow_contrastrules tested and sampled the wrong part of the page.vl_nearest()misplaced text and rounded rects.
scene_model() was never affected — it takes element geometry from element_table(), which was correct — so vellumwidget's contract is untouched.
Why it took this long to find
The bug predates 0.6.0; it has been there since the lint table was introduced. It was invisible to 1691 tests because they all draw into the default full-page viewport, where the transform is the identity and local coordinates are device coordinates.
It surfaced immediately on the first deliberately realistic render — a panel offset from the page origin, with gridlines and labelled markers. That render is now checked in as _docs/critical-plots.R, which exists precisely because this class of bug is invisible to unit tests and obvious to the eye.
The two new tests use an offset panel and assert that both tables agree on a device position, and that repel actually clears the markers there.
All 44 regression artifacts remain byte-identical: this changes reported geometry, not rendering.
vellum 0.6.0
Six phases of engine work since 0.5.1. The theme running through them: vellum resolves layout, text metrics and geometry before it draws, and this release spends that in six directions at once.
38 new exports. vellumplot (2741 tests) and vellumwidget (181) both pass unchanged against it.
Typography
text_grob(width=, height=, align=, fit=)— wrap a label to an absolute measure, align left/centre/right/justify, and auto-fit the font until the block fits. Break decisions use the shaped width of each candidate line, so kerning and OpenType features are accounted for and a line can never render wider than it measured.text_path_grob()— text set along a curve, with halos, features and all three backends intact.- plotmath: declined, with reasoning recorded. No math-run node type; a
$...$span inmd()if ever needed.
Placement
vl_place()/vl_repel()— label collision solving over resolved geometry. Solves in device pixels and applies the answer as an absolute offset, so faceted, polar and warped panels are all solved together, with no second compile.vl_empty_region()— the largest empty rectangle, for putting a legend where the marks are not.vl_hull()/vl_buffer()— convex and concave hulls, and outward offsetting.
Geometry
vl_path_op()— boolean union / intersect / difference / xor. The result is geometry, so it can be gradient-filled, stroked along its new boundary, and used as the operand of another boolean.vl_contour()/contour_grob()— marching squares over any matrix, chained into polylines.svg_grob()/vl_svg_path()— the full SVG pathdgrammar, so icon sets become crisp vector markers.
Accessible and reproducible output
- Tagged PDF from the existing per-mark
id/role/namechannel — no new annotation API.role = "presentation"marks furniture as a PDF artifact so screen readers skip it. scene_fonts()/font_pin()/font_check()— make the one part of the determinism claim vellum does not control visible and checkable.
Output reach
- Animated SVG (
vl_render_animation(format = "svg")) — resolution-independent, honoursprefers-reduced-motion. Choose it by mark count: it beats GIF on line art and loses on dense scatter, and warns above 5 MB. pdf_pages()— several scenes as one document, pages of differing size, tagging preserved.render_all()— render independent scenes across cores, byte-identical to sequential.
Addressable marks
- Every mark family can now carry a
key. Previously only 6 of 18 constructors could, so a line, an area, a choropleth region or a data label could never be hovered, tooltipped, brushed or cross-filtered. Now a whole series can be one addressable thing. vl_nearest()/element_geometry()— hit-testing against the real shape rather than the bounding box, so a diagonal is not matched from the far corner of its bbox.
Fixes that change output
Four bugs whose fixes deliberately alter rendering:
- Multi-line text hung below its anchor by
(n-1)·lineheight/2. - Multi-line text collapsed onto one line in SVG.
- A halo ate neighbouring glyphs on text set along a path.
vl_repel()was defeated by a panel background (29 of 32 labels unresolvable) and could push labels off the page.
Plus a portability fix: render_all() now respects _R_CHECK_LIMIT_CORES_, without which any package depending on vellum would have failed its own R CMD check.
See NEWS.md for the full list.
vellum 0.5.1
Patch release.
Bug fixes
sector_grob()andloop_grob()no longer render vertically mirrored (#14). Sector arcs were built directly in the y-down device frame (cy + r·sin θ) while every other primitive maps coordinates through the y-up native frame, so a sector drawn at angleθlanded where the others would place−θ— the documented "0 at 3 o'clock, counter-clockwise" contract was mirrored across the horizontal axis. A centroid label/point overlaid on a pie / donut / rose / sunburst therefore sat on the mirror image of its wedge. Sector fills, strokes, arrowheads, and hand-drawn (sketch=) wedges — andloop_grob()'s teardropangle, which shared the same convention — now all honour the y-up angle contract.
Full changelog: v0.5.0...v0.5.1