--- title: "Accessible output, and fonts you can trust" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Accessible output, and fonts you can trust} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r} #| include: false knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 5, fig.height = 3.2, dpi = 96, dev = "png" ) library(vellum) ``` Two things in this article are less features than promises being kept: a PDF a screen reader can navigate, and a check on the one part of vellum's determinism claim that vellum does not control. ## Marks that mean something vellum has carried per-mark `id`, `role` and `name` since early on, and has emitted them into SVG as `data-*` and ARIA `role`. The same channel now builds a PDF **structure tree**. The important thing is that this needs no new annotation API. If a scene is already marked up for the web, it is already marked up for PDF. ```{r} region <- c("North", "South", "East", "West") sales <- c(42, 31, 55, 28) plot <- vl_scene(5, 3.2, dpi = 96, bg = "white") |> draw(rect_grob(y = 0.55, height = 0.72, gp = vl_gpar(fill = "grey97", col = NA), role = "presentation", name = "panel background")) |> draw(text_grob("Sales by region", y = 0.94, gp = vl_gpar(fontsize = 13), role = "heading", name = "Sales by region")) for (i in seq_along(region)) { plot <- plot |> draw(rect_grob(x = (i - 0.5) / 4, y = 0.2 + sales[i] / 200, width = 0.14, height = sales[i] / 100, gp = vl_gpar(fill = "#2C6FA6", col = NA), role = "img", name = sprintf("%s: %d units", region[i], sales[i]))) |> draw(text_grob(region[i], x = (i - 0.5) / 4, y = 0.1, gp = vl_gpar(fontsize = 9, col = "grey35"), role = "presentation", name = region[i])) } plot <- describe(plot, title = "Sales by region", desc = "A bar chart of unit sales across four regions. East is highest at 55; West lowest at 28." ) display(plot) ``` `name` becomes the alt text, so write it as a sentence someone will hear, not as an internal identifier — `"East: 55 units"`, not `"bar_3"`. ```{r} pdf <- scene_pdf(plot) c(tagged = length(grepRaw("StructTreeRoot", pdf, fixed = TRUE)) > 0, has_alt = length(grepRaw("/Alt(", pdf, fixed = TRUE)) > 0) ``` What lands in the file: a `StructTreeRoot`, a `Figure` for the plot as a whole carrying the `describe()` text, and one structure element per marked-up mark. They go in **draw order**, which for a graphic *is* reading order — it is the order the author put the marks in. ### Roles `role` is an ARIA-flavoured string; PDF structure types are not ARIA. The mapping is therefore deliberately small and documented rather than a general translation: | `role` | PDF structure element | |---|---| | `"heading"` | `H1`, titled from `name` | | `"paragraph"`, `"text"` | `P` | | `"caption"` | `Caption` | | `"listitem"` | `LI` | | `"presentation"`, `"none"`, `"decorative"` | **Artifact** — skipped by assistive tech | | anything else | `Figure`, with `Alt` from `name` | `Figure` is the default because it is the right one for a mark in a graphic, and because it is the type PDF/UA requires alt text for. The artifact case earns its place: a screen reader announcing every gridline and panel background is worse than one announcing none. Mark the furniture `"presentation"` and it is excluded from the tree entirely. ### Two things this does not change **Pixels.** Structure is metadata; a marked-up scene rasterises identically to the same scene without the marks. **Untagged output.** A scene with no marked-up nodes produces exactly the PDF it always did, byte for byte. Tagging is opt-in by virtue of annotating. There is one interaction worth knowing: per-mark tagging and the whole-figure tag from `describe()` alone are mutually exclusive, because PDF does not allow one tagged content span inside another. When any mark is annotated, the marks own the tagging and the `describe()` text becomes the root figure's alt. ## Fonts you can trust `DESIGN.md` opens by claiming identical pixels on every OS and in CI. Layout, shaping and rasterisation deliver that. **Font resolution does not**, and cannot: `"sans"` is Helvetica on macOS, DejaVu Sans on many Linux systems, Arial elsewhere. The pixels then differ for a reason the claim does not cover, and until now nothing said so. `scene_fonts()` reports the font *files* a scene's text actually resolved to — read off the shaped glyphs, so it says what was used rather than what would be picked if asked again: ```{r} scene_fonts(plot) ``` `font_pin()` records that, and `font_check()` compares later: ```{r} pin <- font_pin(plot) pin nrow(font_check(plot, pin)) ``` ```{r} other <- vl_scene(4, 2, dpi = 96) |> draw(text_grob("hello", gp = vl_gpar(fontfamily = "mono"))) font_check(other, pin, on_mismatch = "ignore") ``` ### What it is for A pin turns "identical pixels" from an assumption into an assertion. Put one next to a reference image and a failing comparison can be *attributed*: ```{r} #| eval: false test_that("the figure is unchanged", { expect_equal(nrow(font_check(my_plot(), readRDS("fonts.rds"))), 0) expect_snapshot_file(render(my_plot(), "plot.png")) }) ``` Without the first line the second fails on any machine with a different font stack and tells you nothing about your change. ### What it deliberately is not It does not *make* fonts reproducible. It cannot install a font, and it does not bundle one into the scene — vellum resolves fonts through \pkg{systemfonts} precisely so that it agrees with the rest of R's graphics stack, and embedding files would break that agreement and raise licensing questions vellum should not answer on your behalf. When you need a guarantee rather than a check, register the exact file you mean with `systemfonts::register_font()` and pin that. The honest tool here is a check, and saying so is better than a feature that implies more than it delivers. ## Still missing: selectable, faithful SVG text For completeness, because it is the obvious next question. SVG text today is either **faithful** (`text = "outline"`, glyph outlines — identical everywhere, not selectable) or **selectable** (`text = "native"`, real `` — selectable, correct only where the same fonts are installed). Embedding a subset font would give both, and vellum does not do it yet. That is blocked on tooling rather than on effort. The subsetter already in the dependency tree removes the `cmap` table by design, because it targets PDF CID fonts where the PDF supplies its own character mapping — so its output cannot be a web font, which needs `cmap` to shape text at all. Compounding it, system fonts are frequently `.ttc` collections (2.3 MB for Helvetica here), so embedding the file wholesale is both large and not directly usable as a `@font-face` source. Doing this properly needs a `cmap`-preserving subsetter, face extraction from collections, and ideally WOFF2 compression — a self-contained project rather than a tail item. ## Where to go next - `vignette("inspecting-scenes")`: `vl_lint()`, whose `low_contrast`, `tiny_text` and `cvd_collision` rules cover the other half of accessibility — whether the figure can be *seen*. Its `font_fallback` rule is the companion to `font_pin()` below: one asks whether the fonts moved, the other whether a character has any glyph at all on this machine. - `vignette("render-quality")`: colour-vision simulation. - `vignette("scene-contract")`: what the `id`/`role`/`name` channel promises to layers above vellum.