git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/page.rs (23K)

  1 //! The 2D page context — a printed sheet, composited from layers.
  2 //!
  3 //! This is a SECOND context, deliberately not the geometry graph. Its currency
  4 //! is a [`Page`] rather than a `Detail`, its coordinates are inches rather than
  5 //! world units, its origin is the top-left corner with y running DOWN, and
  6 //! nothing in it has a point id, an attribute or a normal. The geometry graph
  7 //! describes a thing you will make; a page describes a thing you will print.
  8 //! Smuggling one into the other means a `Detail` that is secretly a raster and
  9 //! a viewport that has to guess which it is holding, so they stay apart: page
 10 //! nodes resolve through [`resolve_page`], never through
 11 //! `generate_single_node_geometry_with_errors`, and contribute no geometry to
 12 //! the viewport at all.
 13 //!
 14 //! Inches, not millimetres, because the page's own reason for existing is
 15 //! paper, and paper is specified in inches by the sources this came from (8.5 ×
 16 //! 11 is the default everywhere in the family). The World Unit declaration that
 17 //! governs the geometry graph does not reach here — a sheet is a sheet at any
 18 //! model scale.
 19 //!
 20 //! **Resolution is a property of the page, not of the export.** A page carries
 21 //! its DPI, the raster is that many pixels per inch, and the PNG says so in its
 22 //! pHYs chunk — so a printer, a slicer or a browser lays the file out at the
 23 //! physical size it was composed at instead of guessing 96. A page composed at
 24 //! 300 DPI and printed is 8.5 inches wide; the same pixels labelled 72 are
 25 //! nearly four feet.
 26 
 27 use std::path::Path;
 28 
 29 /// Declare a PNG's pixels to be sRGB, the way the spec asks for.
 30 ///
 31 /// `Encoder::set_srgb` did this in one call and is deprecated; its replacement
 32 /// `set_source_srgb` writes ONLY the sRGB chunk, dropping the gAMA and cHRM
 33 /// fallbacks that PNG 11.3.2.5 says to write beside it for decoders that do
 34 /// not understand sRGB. Swapping one call for the other therefore changes the
 35 /// file — silently, and only for old decoders, which is the worst way for a
 36 /// deprecation fix to change behaviour. Both of this app's PNG writers go
 37 /// through here instead, so they agree and neither one drifts.
 38 pub fn mark_srgb<W: std::io::Write>(encoder: &mut png::Encoder<W>) {
 39     encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
 40     encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
 41     encoder.set_source_chromaticities(png::SourceChromaticities {
 42         white: (png::ScaledFloat::from_scaled(31270), png::ScaledFloat::from_scaled(32900)),
 43         red: (png::ScaledFloat::from_scaled(64000), png::ScaledFloat::from_scaled(33000)),
 44         green: (png::ScaledFloat::from_scaled(30000), png::ScaledFloat::from_scaled(60000)),
 45         blue: (png::ScaledFloat::from_scaled(15000), png::ScaledFloat::from_scaled(6000)),
 46     });
 47 }
 48 
 49 /// One printed sheet: a physical size, a resolution, and the pixels in between.
 50 ///
 51 /// Pixels are straight-alpha linear RGBA. Straight rather than premultiplied
 52 /// because every operation here composites INTO an opaque page, so the extra
 53 /// multiply buys nothing and the stored values stay the ones the parameters
 54 /// asked for.
 55 #[derive(Clone)]
 56 pub struct Page {
 57     /// Physical size in inches, before orientation is applied.
 58     pub size: [f32; 2],
 59     /// Pixels per inch.
 60     pub dpi: u32,
 61     pub width: u32,
 62     pub height: u32,
 63     pub pixels: Vec<[f32; 4]>,
 64 }
 65 
 66 /// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
 67 /// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
 68 /// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
 69 /// large-format print) at 1200 DPI.
 70 const MAX_PIXELS: u64 = 356_000_000;
 71 
 72 impl Page {
 73     /// A blank sheet filled with `color`.
 74     ///
 75     /// The size is clamped to something a printer could accept rather than
 76     /// rejected: a page node whose size parameter is being dragged passes
 77     /// through zero, and a context that returns an error there flickers.
 78     pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
 79         let size = [size[0].max(0.01), size[1].max(0.01)];
 80         let dpi = dpi.clamp(1, 2400);
 81         let mut width = (size[0] * dpi as f32).round().max(1.0) as u32;
 82         let mut height = (size[1] * dpi as f32).round().max(1.0) as u32;
 83         if width as u64 * height as u64 > MAX_PIXELS {
 84             // Scale both axes by the same factor so the aspect — the thing the
 85             // page size actually means — survives the clamp.
 86             let scale = (MAX_PIXELS as f64 / (width as f64 * height as f64)).sqrt();
 87             width = ((width as f64 * scale) as u32).max(1);
 88             height = ((height as f64 * scale) as u32).max(1);
 89         }
 90         Page { size, dpi, width, height, pixels: vec![color; (width * height) as usize] }
 91     }
 92 
 93     /// Pixels per inch as a float, measured from the raster rather than read
 94     /// from `dpi` — after a clamp those disagree, and every drawing operation
 95     /// wants the one that describes the pixels it is about to touch.
 96     pub fn scale(&self) -> f32 {
 97         self.width as f32 / self.size[0]
 98     }
 99 
100     /// Paint the whole sheet.
101     pub fn fill(&mut self, color: [f32; 4]) {
102         for p in &mut self.pixels {
103             *p = color;
104         }
105     }
106 
107     /// An axis-aligned rectangle in INCHES from the top-left corner.
108     ///
109     /// Coverage is exact area, not a coin flip on the pixel centre. A printed
110     /// grid is mostly hairlines — a 0.01 inch rule at 300 DPI is three pixels,
111     /// at 100 DPI is one — and a binary fill makes every line snap to whole
112     /// pixels, so a ruled sheet comes out with lines alternating between one
113     /// and two pixels wide down its length. The eye reads that as a wobble in
114     /// the paper, not as aliasing, and it survives printing.
115     pub fn rect(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: [f32; 4]) {
116         let s = self.scale();
117         let (px0, px1) = (x0.min(x1) * s, x0.max(x1) * s);
118         let (py0, py1) = (y0.min(y1) * s, y0.max(y1) * s);
119         if px1 <= 0.0 || py1 <= 0.0 || px0 >= self.width as f32 || py0 >= self.height as f32 {
120             return;
121         }
122         let i0 = px0.floor().max(0.0) as u32;
123         let j0 = py0.floor().max(0.0) as u32;
124         let i1 = (px1.ceil() as i64).clamp(0, self.width as i64) as u32;
125         let j1 = (py1.ceil() as i64).clamp(0, self.height as i64) as u32;
126         for j in j0..j1 {
127             let cy = (py1.min(j as f32 + 1.0) - py0.max(j as f32)).clamp(0.0, 1.0);
128             if cy <= 0.0 {
129                 continue;
130             }
131             for i in i0..i1 {
132                 let cx = (px1.min(i as f32 + 1.0) - px0.max(i as f32)).clamp(0.0, 1.0);
133                 if cx > 0.0 {
134                     self.blend(i, j, color, cx * cy);
135                 }
136             }
137         }
138     }
139 
140     /// `color` over the pixel, its alpha scaled by `coverage`.
141     fn blend(&mut self, x: u32, y: u32, color: [f32; 4], coverage: f32) {
142         let a = color[3] * coverage;
143         if a <= 0.0 {
144             return;
145         }
146         let dst = &mut self.pixels[(y * self.width + x) as usize];
147         let out_a = a + dst[3] * (1.0 - a);
148         if out_a <= 0.0 {
149             *dst = [0.0; 4];
150             return;
151         }
152         for c in 0..3 {
153             // Straight alpha, so the destination's contribution is weighted by
154             // its own alpha and the result divided back out.
155             dst[c] = (color[c] * a + dst[c] * dst[3] * (1.0 - a)) / out_a;
156         }
157         dst[3] = out_a;
158     }
159 
160     /// A ruled grid: cells of `cell` inches filled with `cell_color`, ruled
161     /// with lines `thickness` inches wide in `line_color`.
162     ///
163     /// Lines are centred ON their coordinate, not placed beside it, so a grid
164     /// and a second grid at twice the cell size share their rules exactly
165     /// instead of straddling them — which is the whole point of drawing two.
166     /// The sheet's own edges are ruled too: a grid that stops one line short
167     /// looks like a mistake rather than a margin.
168     pub fn grid(&mut self, cell: f32, thickness: f32, cell_color: [f32; 4], line_color: [f32; 4]) {
169         if cell_color[3] > 0.0 {
170             self.rect(0.0, 0.0, self.size[0], self.size[1], cell_color);
171         }
172         let cell = cell.max(1.0 / self.scale());
173         let half = (thickness * 0.5).max(0.25 / self.scale());
174         let mut x = 0.0;
175         while x <= self.size[0] + 1e-4 {
176             self.rect(x - half, 0.0, x + half, self.size[1], line_color);
177             x += cell;
178         }
179         let mut y = 0.0;
180         while y <= self.size[1] + 1e-4 {
181             self.rect(0.0, y - half, self.size[0], y + half, line_color);
182             y += cell;
183         }
184     }
185 
186     /// A border `width` inches wide, drawn INSIDE the sheet's edge.
187     ///
188     /// Inside rather than centred on the edge, because half a border off the
189     /// paper is half a border: the printed page has no bleed here, and a
190     /// parameter that says 0.5 inches should put 0.5 inches of ink on the
191     /// sheet.
192     pub fn border(&mut self, width: f32, inset: f32, color: [f32; 4]) {
193         let (w, h) = (self.size[0], self.size[1]);
194         let width = width.max(0.0).min(w.min(h) * 0.5);
195         let (a, b) = (inset, inset + width);
196         self.rect(a, a, w - a, b, color);
197         self.rect(a, h - b, w - a, h - a, color);
198         self.rect(a, b, b, h - b, color);
199         self.rect(w - b, b, w - a, h - b, color);
200     }
201 
202     /// The page as 8-bit sRGB RGBA, row-major from the top — what both the GPU
203     /// upload and the PNG encoder want.
204     pub fn to_rgba8(&self) -> Vec<u8> {
205         let mut out = Vec::with_capacity(self.pixels.len() * 4);
206         for p in &self.pixels {
207             for c in 0..4 {
208                 out.push((p[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
209             }
210         }
211         out
212     }
213 
214     /// Write a PNG that knows its own physical size.
215     ///
216     /// The pHYs chunk carries pixels per METRE, which is the only unit PNG
217     /// offers — so the DPI round-trips through a conversion and comes back a
218     /// hair off. That is the format's limit, not a bug to chase: 300 DPI
219     /// stores as 11811 px/m and reads back as 299.9994.
220     pub fn write_png(&self, path: &Path) -> Result<(), String> {
221         let file = std::fs::File::create(path)
222             .map_err(|e| format!("create {}: {e}", path.display()))?;
223         let mut encoder =
224             png::Encoder::new(std::io::BufWriter::new(file), self.width, self.height);
225         encoder.set_color(png::ColorType::Rgba);
226         encoder.set_depth(png::BitDepth::Eight);
227         mark_srgb(&mut encoder);
228         let per_metre = (self.scale() * 39.370_08).round() as u32;
229         encoder.set_pixel_dims(Some(png::PixelDimensions {
230             xppu: per_metre,
231             yppu: per_metre,
232             unit: png::Unit::Meter,
233         }));
234         let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
235         writer
236             .write_image_data(&self.to_rgba8())
237             .map_err(|e| format!("png write: {e}"))?;
238         writer.finish().map_err(|e| format!("png finish: {e}"))?;
239         Ok(())
240     }
241 }
242 
243 /// Where a run of text sits in the box it is given.
244 #[derive(Clone, Copy, PartialEq, Debug)]
245 pub enum HAlign {
246     Left,
247     Center,
248     Right,
249 }
250 
251 #[derive(Clone, Copy, PartialEq, Debug)]
252 pub enum VAlign {
253     Top,
254     Middle,
255     Bottom,
256 }
257 
258 /// Everything the text operation needs. A struct rather than a dozen arguments
259 /// because the node has a dozen parameters and threading them positionally is
260 /// how the wrong two get swapped.
261 pub struct TextSpec<'a> {
262     pub text: &'a str,
263     pub font: &'a str,
264     /// Cap height in inches — a "0.1 font size" on a printed page means a tenth
265     /// of an inch of type, not a tenth of a pixel or of the sheet.
266     pub size: f32,
267     pub color: [f32; 4],
268     /// Where the text box's own origin sits on the sheet, in inches.
269     pub at: [f32; 2],
270     pub halign: HAlign,
271     pub valign: VAlign,
272     /// Line spacing as a multiple of the font size.
273     pub leading: f32,
274 }
275 
276 impl Default for TextSpec<'_> {
277     fn default() -> Self {
278         TextSpec {
279             text: "",
280             font: "",
281             size: 0.1,
282             color: [0.0, 0.0, 0.0, 1.0],
283             at: [0.5, 0.5],
284             halign: HAlign::Left,
285             valign: VAlign::Top,
286             leading: 1.25,
287         }
288     }
289 }
290 
291 impl Page {
292     /// Draw shaped text onto the sheet.
293     ///
294     /// Shaping and rasterizing both come from cosmic-text, which the toolkit
295     /// already owns — the alternative is a second font stack in the same
296     /// process disagreeing with the first about what a font is called.
297     ///
298     /// The size is converted to pixels here, at the page's own scale, so the
299     /// same page composed at 150 and at 600 DPI prints identical type at
300     /// different sample counts. That is the property that makes resolution a
301     /// page parameter rather than an export one.
302     pub fn text(
303         &mut self,
304         fonts: &mut cce_ui::cosmic_text::FontSystem,
305         cache: &mut cce_ui::cosmic_text::SwashCache,
306         spec: &TextSpec,
307     ) {
308         use cce_ui::cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
309         if spec.text.is_empty() || spec.size <= 0.0 {
310             return;
311         }
312         let px = (spec.size * self.scale()).max(1.0);
313         let mut buffer = Buffer::new(fonts, Metrics::new(px, px * spec.leading.max(0.1)));
314         // No width or height limit: the box is measured from the shaped text
315         // rather than the text wrapped into a box. A printed label that
316         // silently wraps is worse than one that runs long, because the run-on
317         // is visible and the wrap looks deliberate.
318         buffer.set_size(fonts, None, None);
319         let attrs = if spec.font.trim().is_empty() {
320             Attrs::new()
321         } else {
322             Attrs::new().family(Family::Name(spec.font.trim()))
323         };
324         buffer.set_text(fonts, spec.text, attrs, Shaping::Advanced);
325         buffer.shape_until_scroll(fonts, false);
326 
327         // Measure what was actually shaped, so alignment is against the ink
328         // rather than against the requested size.
329         let mut text_w: f32 = 0.0;
330         let mut lines = 0.0f32;
331         for run in buffer.layout_runs() {
332             text_w = text_w.max(run.line_w);
333             lines += 1.0;
334         }
335         let line_h = px * spec.leading.max(0.1);
336         let text_h = lines.max(1.0) * line_h;
337 
338         let ox = spec.at[0] * self.scale()
339             - match spec.halign {
340                 HAlign::Left => 0.0,
341                 HAlign::Center => text_w * 0.5,
342                 HAlign::Right => text_w,
343             };
344         let oy = spec.at[1] * self.scale()
345             - match spec.valign {
346                 VAlign::Top => 0.0,
347                 VAlign::Middle => text_h * 0.5,
348                 VAlign::Bottom => text_h,
349             };
350 
351         let color = cce_ui::cosmic_text::Color::rgba(255, 255, 255, 255);
352         let (w, h) = (self.width as i32, self.height as i32);
353         let mut hits: Vec<(u32, u32, f32)> = Vec::new();
354         buffer.draw(fonts, cache, color, |x, y, gw, gh, c| {
355             // cosmic-text hands back a filled rect per span; the alpha is the
356             // glyph's coverage. The colour it carries is the one passed in,
357             // which is why that is opaque white — the page's own colour is
358             // applied here, so a coloured glyph is not double-tinted.
359             let a = c.a() as f32 / 255.0;
360             if a <= 0.0 {
361                 return;
362             }
363             for dy in 0..gh as i32 {
364                 for dx in 0..gw as i32 {
365                     let (px, py) = (x + dx + ox as i32, y + dy + oy as i32);
366                     if px >= 0 && py >= 0 && px < w && py < h {
367                         hits.push((px as u32, py as u32, a));
368                     }
369                 }
370             }
371         });
372         for (x, y, a) in hits {
373             self.blend(x, y, spec.color, a);
374         }
375     }
376 }
377 
378 // ---------------------------------------------------------------------------
379 // The node chain
380 // ---------------------------------------------------------------------------
381 
382 use crate::app::FsNode;
383 use crate::geometry::{find_node_by_name, node_param_f32, node_param_str, node_param_vec3};
384 use glam::Vec3;
385 
386 /// Whether a node belongs to the page context rather than the geometry graph.
387 ///
388 /// The two do not mix, and this is the one place that says so. A page node
389 /// contributes nothing to the viewport's geometry and a geometry node cannot
390 /// feed a page, so the network is really two networks sharing an editor — the
391 /// same way Houdini's contexts do, and for the same reason: a raster and a
392 /// mesh have no operation in common.
393 pub fn is_page_node(node_type: &str) -> bool {
394     matches!(
395         node_type.to_ascii_lowercase().as_str(),
396         "page" | "page_grid" | "page_border" | "page_text"
397     )
398 }
399 
400 /// Named sheet sizes, in inches, portrait.
401 ///
402 /// A4 is metric and converts to 8.268 × 11.693 — carried at that precision
403 /// rather than rounded, because a rounded A4 prints with a visible margin
404 /// error at the bottom of the sheet.
405 fn preset_size(name: &str) -> Option<[f32; 2]> {
406     match name.trim().to_ascii_lowercase().as_str() {
407         "letter" => Some([8.5, 11.0]),
408         "a4" => Some([8.267_717, 11.692_913]),
409         "legal" => Some([8.5, 14.0]),
410         "tabloid" => Some([11.0, 17.0]),
411         _ => None,
412     }
413 }
414 
415 fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
416     let c = node_param_vec3(node, name, fallback);
417     [c.x, c.y, c.z, 1.0]
418 }
419 
420 fn toggle_of(node: &FsNode, name: &str) -> bool {
421     matches!(node_param_str(node, name, "false").trim().to_ascii_lowercase().as_str(), "true" | "1" | "on")
422 }
423 
424 /// Compose the page `target` describes, resolving its input chain.
425 ///
426 /// `visited` guards cycles by node id exactly as the geometry resolvers do —
427 /// the page context is a second graph, not a second kind of graph.
428 pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Page> {
429     if visited.contains(&target.id) {
430         return None;
431     }
432     visited.push(target.id.clone());
433 
434     let kind = target.node_type.to_ascii_lowercase();
435     if kind == "page" {
436         let preset = node_param_str(target, "Preset", "Letter");
437         let size = preset_size(&preset).unwrap_or([
438             node_param_f32(target, "Width", 8.5),
439             node_param_f32(target, "Height", 11.0),
440         ]);
441         // Landscape is the same sheet turned, not a different sheet: swap the
442         // axes rather than asking for a second pair of numbers.
443         let size = if node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape")
444         {
445             [size[1], size[0]]
446         } else {
447             size
448         };
449         let dpi = node_param_f32(target, "Resolution", 300.0).round().max(1.0) as u32;
450         return Some(Page::new(size, dpi, color_of(target, "Color", Vec3::ONE)));
451     }
452 
453     // Everything else composites onto its input, so a chain with no page at
454     // the bottom of it has nothing to draw on and resolves to nothing. That is
455     // the honest answer: a border with no page is not a page with a border —
456     // and it is also how an Export node in a GEOMETRY chain falls through to
457     // the geometry resolvers rather than being claimed by this one.
458     let input = find_node_by_name(root, node_param_str(target, "Input", "").trim())?;
459     let mut page = resolve_page(root, input, visited)?;
460 
461     match kind.as_str() {
462         // Export belongs to neither context and passes through both. What it
463         // writes is decided by what reaches it: a page becomes a PNG, geometry
464         // becomes the mesh format its Format parameter names.
465         "export" => {}
466         "page_grid" => {
467             let cell_color = if toggle_of(target, "Fill Cells") {
468                 color_of(target, "Cell Color", Vec3::ONE)
469             } else {
470                 [0.0; 4]
471             };
472             page.grid(
473                 node_param_f32(target, "Cell Size", 0.25),
474                 node_param_f32(target, "Line Width", 0.01),
475                 cell_color,
476                 color_of(target, "Line Color", Vec3::ZERO),
477             );
478         }
479         "page_border" => page.border(
480             node_param_f32(target, "Width", 0.06),
481             node_param_f32(target, "Inset", 0.4),
482             color_of(target, "Color", Vec3::ZERO),
483         ),
484         "page_text" => {
485             let text = node_param_str(target, "Text", "");
486             let font = node_param_str(target, "Font", "");
487             let spec = TextSpec {
488                 text: &text,
489                 font: &font,
490                 size: node_param_f32(target, "Size", 0.25),
491                 color: color_of(target, "Color", Vec3::ZERO),
492                 at: [node_param_f32(target, "X", 4.25), node_param_f32(target, "Y", 0.8)],
493                 halign: match node_param_str(target, "Horizontal", "Center").as_str() {
494                     "Left" => HAlign::Left,
495                     "Right" => HAlign::Right,
496                     _ => HAlign::Center,
497                 },
498                 valign: match node_param_str(target, "Vertical", "Top").as_str() {
499                     "Middle" => VAlign::Middle,
500                     "Bottom" => VAlign::Bottom,
501                     _ => VAlign::Top,
502                 },
503                 leading: node_param_f32(target, "Leading", 1.25),
504             };
505             with_fonts(|fonts, cache| page.text(fonts, cache, &spec));
506         }
507         _ => return None,
508     }
509     Some(page)
510 }
511 
512 /// The page context's font stack, created once.
513 ///
514 /// System fonts included, because a page node names its font by family — the
515 /// source family's default is "Lato" — and a font stack that only knows the
516 /// bundled house faces would silently substitute for every named font a user
517 /// actually owns. Shaping the app's own widgets stays on the toolkit's
518 /// geometry font system; this one is for ink on paper.
519 fn with_fonts<R>(
520     f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
521 ) -> R {
522     use std::sync::{Mutex, OnceLock};
523     type Stack = (cce_ui::cosmic_text::FontSystem, cce_ui::cosmic_text::SwashCache);
524     static FONTS: OnceLock<Mutex<Stack>> = OnceLock::new();
525     let stack = FONTS.get_or_init(|| {
526         Mutex::new((
527             cce_ui::create_font_system_with_system_fonts(),
528             cce_ui::cosmic_text::SwashCache::new(),
529         ))
530     });
531     let mut guard = stack.lock().unwrap();
532     let (fonts, cache) = &mut *guard;
533     f(fonts, cache)
534 }
535 
536 /// The page a network level displays, if it displays one.
537 ///
538 /// The same rule the viewport follows for geometry: draw what is visible at
539 /// the level being shown. Several page chains at one level is ambiguous, so
540 /// the LAST visible page node wins — the one furthest down the roster, which
541 /// is the one most recently added.
542 pub fn displayed_page(root: &FsNode, level: &FsNode) -> Option<Page> {
543     let target = level
544         .children
545         .iter()
546         .filter(|c| is_page_node(&c.node_type) && c.geometry_visible)
547         .next_back()?;
548     resolve_page(root, target, &mut Vec::new())
549 }
550 
551 /// The font stack, for tests that draw text without a node behind them.
552 #[cfg(test)]
553 pub fn with_fonts_for_test<R>(
554     f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
555 ) -> R {
556     with_fonts(f)
557 }