Compare commits

..
5 Commits
Author SHA1 Message Date
BFlatandClaude Opus 5.5 079f9a8b9a Fix #296: variable fonts list and draw every named style
The font database made one style per font file, so a variable font
such as Figtree[wght].ttf listed only its default instance (Light).
Faces now also yield their fvar named instances, each a style of the
family named by its subfamily name (the default instance is the face
itself; unnamed or duplicate instances are left out; axes and
instances read are capped). The system font scan reads the fvar table
beside the name table, so installed instances are listed before the
file is loaded and found by name and by PostScript name.

An instance face carries its axis settings: skrifa draws its outlines
and reads its metrics and advances at that location, harfrust shapes
with a ShaperInstance, so layout, the canvas, Create Outlines and hit
testing all use the instance. PDF export embeds it through krilla's
Font::new_variable, SVG @font-face through subsetter's
subset_with_variations: both write a static font of that instance.

Tests build a small variable font in code (test_fonts, via
write-fonts; no font file is committed) and check listing, resolving,
glyphs and advances, PDF and SVG export and damaged fvar/gvar tables;
Bahnschrift and Segoe UI Variable are checked where installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:01:26 +07:00
BFlat 8e40794153 Merge pull request #325 from storytold/fix/issue-290
Fix #290: hovering another menu title switches the open menu
2026-10-07 20:54:33 +07:00
BFlatandClaude Opus 5.5 ee7a7ebfac Fix #290: hovering another menu title switches the open menu
On the in-window menu bar (Windows and Linux) each top-level menu needed
its own click: with File open, moving the pointer onto Edit or Object did
nothing. Now, as on a native menu bar, while one menu is open a moving
pointer that reaches another title opens that menu instead (ported from
PhotoCraft). A pointer resting on a title doesn't switch, and a tall menu
that covers the bar doesn't switch to the title underneath it. The native
macOS menu bar is unchanged.

Also adds a regression test for #292: the Simplify, Offset Path, Move,
Rotate, Split Into Grid and Average dialogs are 366 pt wide in a 1600 pt
window (already fixed by #239).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:32:03 +07:00
BFlat 0d4d125f9b Merge pull request #323 from storytold/fix/issue-291
Fix #291: live corners stay circular when a rectangle is scaled unevenly
2026-10-07 20:28:06 +07:00
BFlatandClaude Opus 5.5 deaec7fc71 Fix #291: live corners stay circular when a rectangle is scaled unevenly
A live rectangle kept every transform in its `xf`, so a non-uniform scale
(a bounding-box drag, Object > Transform > Scale) stretched the rounded
corners built in untransformed space into elliptical arcs. Scale Corners
off divided the radii by the mean scale, which only compensated uniform
scales.

LiveShape::transform now folds a scale along the rectangle's own sides
(any rotation or reflection, no shear) into w/h and keeps only rotation,
reflection and position in `xf`, so the radii are document lengths and the
path is regenerated with circular corners. Radii scale by the mean scale
(sqrt|det|), which keep_corners undoes for Scale Corners off. Shears and
plain moves keep composing into `xf` as before, so files saved with a
scale in `xf` keep their exact geometry until next scaled or rotated.
A corner-widget drag starts from the radius as drawn (clamped to fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:25:41 +07:00
19 changed files with 1036 additions and 89 deletions

No files matched your search

Generated
+1
View File
@@ -4553,6 +4553,7 @@ dependencies = [
"subsetter",
"vectorcraft-doc",
"vectorcraft-geom",
"write-fonts",
]
[[package]]
+5 -5
View File
@@ -100,14 +100,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
@@ -119,7 +119,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales.
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -154,7 +154,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -190,7 +190,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
+152 -10
View File
@@ -75,12 +75,16 @@ impl LayerColor {
#[serde(tag = "shape", rename_all = "lowercase")]
pub enum LiveShape {
Rectangle {
/// Untransformed width/height.
/// Untransformed width/height. Transforms fold their scale into these (see
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
w: f64,
h: f64,
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
radii: [f64; 4],
/// Maps the untransformed shape (origin at its top-left) into the document.
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
xf: Affine,
},
Ellipse {
@@ -129,12 +133,44 @@ impl LiveShape {
LiveShape::Line { a, b } => shapes::line(*a, *b),
}
}
pub fn transform(&mut self, a: Affine) {
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
LiveShape::Rectangle { w, h, radii, xf } => {
let m = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
if moves_only || !orthogonal || !finite {
*xf = m;
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
*xf = a * *xf;
false
}
LiveShape::Line { a: p, b } => {
*p = a * *p;
*b = a * *b;
false
}
}
}
@@ -613,11 +649,10 @@ impl Node {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
l.transform(a);
if sc.keep_corners
&& let Some(k) = k
&& l.keep_corners(k)
{
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
let reshaped = l.transform(a);
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
if reshaped || kept {
*path = l.to_path();
}
}
@@ -1135,6 +1170,113 @@ mod tests {
assert_eq!(l.label(), "Rectangle");
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn near_xf(a: Affine, b: Affine) -> bool {
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
}
/// Size and radius of a live rectangle, within rounding.
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
let (lw, lh, radii, _) = live_of(n);
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
}
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
assert_eq!(a.len(), 8, "a rounded rectangle");
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
#[test]
fn uneven_scales_keep_live_corners_circular() {
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
n.transform(stretch, KEEP_CORNERS);
assert_live(&n, 300.0, 100.0, 20.0);
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
let b = n.geometric_bounds().unwrap();
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
assert_round_corners(&n, Affine::IDENTITY, 20.0);
// Scale Corners on: the radius scales by the mean scale, still circular.
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
assert_live(&n, 400.0, 100.0, 40.0);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 25.0);
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
}
#[test]
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, place);
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
assert!(near_xf(live_of(&n).3, place));
assert_round_corners(&n, place.inverse(), 10.0);
// A reflection stays in `xf`; the corners stay as they were.
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
let xf = live_of(&n).3;
assert!(near(xf.determinant(), -1.0));
assert_round_corners(&n, xf.inverse(), 10.0);
}
#[test]
fn shears_and_moves_keep_the_old_transform() {
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
let rot = Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, rot);
let before = n.clone();
let a = Affine::scale_non_uniform(2.0, 1.0);
n.transform(a, Scaling::default());
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
assert_eq!(*path, old.transformed(a));
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
// ...and once scaled, has document units and circular corners of its mean radius.
n.transform(Affine::scale(2.0), Scaling::default());
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
assert!(near(live_of(&n).3.determinant(), 1.0));
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+55
View File
@@ -153,6 +153,61 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
}
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a = &path.subpaths[0].anchors;
assert_eq!(a.len(), 8, "a rounded rectangle");
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
}
/// Every corner is a circular arc of radius `r`.
fn round_corners(n: &Node, r: f64) -> bool {
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
}
#[test]
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
// #291: a rounded square widened into a long rectangle keeps round corners.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
let n = node(&s, id);
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
// Undo brings each step back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
}
#[test]
fn a_bounding_box_drag_keeps_live_corners_circular() {
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
s.begin_interaction("Scale").unwrap();
for sx in [1.5, 2.5, 4.0] {
// Dragging the right-middle handle: scaled about the left edge.
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
}
s.commit_interaction().unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
}
#[test]
fn the_journal_records_the_scale_options_used() {
let mut s = session();
+2
View File
@@ -32,4 +32,6 @@ sha2 = { version = "0.10", default-features = false }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
serde_json = { workspace = true }
+5 -1
View File
@@ -1444,7 +1444,11 @@ impl Exporter<'_> {
let name = format!("{} {}", face.family, face.style);
let font = match face.embedding() {
Embedding::Subset => {
let font = krilla::text::Font::new(face.file_data().to_vec().into(), face.face_index()).map(|f| (f, face.units_per_em()));
// A named instance of a variable font is embedded instanced (its own outlines and
// widths, not the default instance's).
let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
let font =
krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em()));
if font.is_none() {
self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines"));
}
+22
View File
@@ -110,6 +110,28 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// A named instance of a variable font is real text in that instance: its embedded subset draws
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
#[test]
fn variable_font_instances_are_real_text_in_their_own_glyphs() {
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
FontDb::global().add_font(variable_font().unwrap());
let mut widths = vec![];
for s in ["Regular", "Bold"] {
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) };
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
let r = pdf(&d, false);
assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings);
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text");
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off =
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}");
widths.push(real.width());
}
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
}
#[test]
fn gradients_on_real_text_stay_where_they_were() {
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+2
View File
@@ -30,6 +30,8 @@ svgtypes = "0.16"
[dev-dependencies]
# Reading embedded font subsets back.
skrifa = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
resvg = { version = "0.45", default-features = false }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
@@ -216,6 +216,31 @@ fn embedded_fonts_hold_only_the_glyphs_used() {
assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face"));
}
/// A named instance of a variable font embeds as a static font of that instance: the variable
/// file would show its default instance in browsers (#296).
#[test]
fn variable_font_instances_embed_as_themselves() {
use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(variable_font().unwrap());
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(VARIABLE_FAMILY, "Regular")), ("ll", style(VARIABLE_FAMILY, "Bold"))]));
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
let fonts = embedded(&o.svg);
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 700], "{:?}", o.warnings);
let mut advances = vec![];
for ((_, w, data), style) in fonts.iter().zip(["Regular", "Bold"]) {
let f = skrifa::FontRef::new(data).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "{w}: a static font");
let gid = f.cmap().unwrap().map_codepoint('l').unwrap();
let adv = f.hmtx().unwrap().advance(gid).unwrap();
let face = db.face(VARIABLE_FAMILY, style).unwrap();
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round(), "{style}");
advances.push(adv);
}
assert!(advances[1] > advances[0], "{advances:?}");
}
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
/// name) with OS/2 `fsType` set to `fs_type`.
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
+13 -2
View File
@@ -15,5 +15,16 @@ kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer.
subsetter = { version = "0.2", default-features = false }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }
[features]
# `vectorcraft_text::test_fonts` for other crates' tests (dev-dependencies only).
test-fonts = ["dep:write-fonts"]
[dev-dependencies]
write-fonts = "0.48"
+33 -10
View File
@@ -60,19 +60,29 @@ impl FontFace {
/// The face as a font file to embed: the glyphs of `chars` alone when its licence allows
/// subsetting, else the whole face. Ligatures, kerning and other layout features are left out
/// of subsets (viewers draw each character's own glyph). `None` when the licence forbids
/// embedding or the font can't be read.
/// of subsets (viewers draw each character's own glyph). A named instance of a variable font
/// ([`FontFace::variations`]) is embedded as a static font of that instance (the variable file
/// would show its default instance), CFF2 outlines becoming TrueType ones. `None` when the
/// licence forbids embedding or the font can't be read.
pub fn embed(&self, chars: &[char]) -> Option<EmbeddedFont> {
let font = self.skrifa()?;
let cff = [b"CFF ", b"CFF2"].iter().any(|t| font.table_data(Tag::new(t)).is_some());
let whole = || Some(EmbeddedFont { data: sfnt(font.table_directory.sfnt_version(), tables(&font))?, cff, subset: false });
let instance = !self.variations().is_empty();
match self.embedding() {
Embedding::Forbidden => None,
// Every glyph of the instance, keeping their ids.
Embedding::Whole if instance => {
let all: Vec<char> = font.charmap().mappings().filter_map(|(c, _)| char::from_u32(c)).collect();
subset(self, &font, &all, true).map(|(data, cff)| EmbeddedFont { data, cff, subset: false })
}
Embedding::Whole => whole(),
Embedding::Subset => match subset(self, &font, chars) {
Some(data) => Some(EmbeddedFont { data, cff, subset: true }),
// Outlines the subsetter can't read (CFF2): what the licence allows anyway.
None => whole(),
Embedding::Subset => match subset(self, &font, chars, false) {
Some((data, cff)) => Some(EmbeddedFont { data, cff, subset: true }),
// Outlines the subsetter can't read: what the licence allows anyway (a named
// instance can't be embedded whole, which would show the default instance).
None if !instance => whole(),
None => None,
},
}
}
@@ -83,10 +93,17 @@ fn tables<'a>(font: &skrifa::FontRef<'a>) -> Vec<(Tag, Cow<'a, [u8]>)> {
font.table_directory.table_records().iter().filter_map(|r| font.table_data(r.tag()).map(|d| (r.tag(), Cow::Borrowed(d.as_bytes())))).collect()
}
/// `face` reduced to the glyphs of `chars`, with a `cmap` mapping them and the face's `OS/2`.
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option<Vec<u8>> {
/// `face` reduced to the glyphs of `chars` (every glyph, keeping their ids, when `all`), with a
/// `cmap` mapping them and the face's `OS/2`; a named instance instanced. Returns the font and
/// whether its outlines are CFF ones.
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char], all: bool) -> Option<(Vec<u8>, bool)> {
let charmap = font.charmap();
let mut remapper = subsetter::GlyphRemapper::new();
if all {
for gid in 0..font.maxp().ok()?.num_glyphs() {
remapper.remap(gid);
}
}
let mut map: Vec<(u32, u16)> = chars
.iter()
.filter_map(|c| {
@@ -96,14 +113,20 @@ fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option
.collect();
map.sort_unstable();
map.dedup_by_key(|m| m.0);
let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?;
let data = if face.variations().is_empty() {
subsetter::subset(face.data(), face.index(), &remapper).ok()?
} else {
let coords: Vec<(subsetter::Tag, f32)> = face.variations().iter().map(|(t, v)| (subsetter::Tag::new(t), *v)).collect();
subsetter::subset_with_variations(face.data(), face.index(), &coords, &remapper).ok()?
};
let sub = skrifa::FontRef::new(&data).ok()?;
let cff = [b"CFF ", b"CFF2"].iter().any(|t| sub.table_data(Tag::new(t)).is_some());
let mut t: Vec<(Tag, Cow<'_, [u8]>)> = tables(&sub).into_iter().filter(|(tag, _)| *tag != CMAP && *tag != OS2).collect();
t.push((CMAP, Cow::Owned(cmap(&map)?)));
if let Some(os2) = font.table_data(OS2) {
t.push((OS2, Cow::Owned(os2_for(os2.as_bytes(), &map))));
}
sfnt(sub.table_directory.sfnt_version(), t)
Some((sfnt(sub.table_directory.sfnt_version(), t)?, cff))
}
/// The face's `OS/2` table with its first and last character fitted to the subset's.
+206 -54
View File
@@ -7,11 +7,11 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
use skrifa::{GlyphId, MetadataProvider, Tag};
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
@@ -28,6 +28,14 @@ static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
/// An axis setting of a variable font: the axis tag (`wght`) and its value in user units (700).
pub type Variation = ([u8; 4], f32);
/// Caps on what a (possibly damaged) variable font can make the database read: axes per font and
/// named instances listed per face.
const MAX_AXES: usize = 64;
const MAX_INSTANCES: usize = 512;
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
@@ -60,6 +68,13 @@ pub struct FontFace {
pub(crate) cap_height: f64,
pub(crate) x_height: f64,
pub(crate) shaper: harfrust::ShaperData,
/// The axis settings of the variable font's named instance this face is (user units: `wght`
/// 700); empty for a static face or a variable font's default instance.
variations: Vec<Variation>,
/// [`Self::variations`] in the font's normalized design space (outlines and metrics).
location: Location,
/// [`Self::variations`] for the shaper (advances, kerning, feature variations).
pub(crate) instance: Option<harfrust::ShaperInstance>,
}
impl std::fmt::Debug for FontFace {
@@ -85,6 +100,17 @@ impl FontFace {
pub(crate) fn index(&self) -> u32 {
self.index
}
/// Where in a variable font's design space the face's outlines and metrics are taken (the
/// default location for a static face).
pub(crate) fn location(&self) -> LocationRef<'_> {
LocationRef::from(&self.location)
}
/// The axis settings of the variable font's named instance this face is (`wght` 700 for
/// Bold), in user units; empty for a static face and a variable font's default instance.
/// The face's file holds the whole variable font: exporters that embed it pass these on.
pub fn variations(&self) -> &[Variation] {
&self.variations
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
@@ -112,7 +138,7 @@ impl FontFace {
/// Advance width of glyph `gid` in font units.
pub fn advance(&self, gid: u32) -> f64 {
self.skrifa()
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
.and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid)))
.map(|a| a as f64)
.unwrap_or(self.upem * 0.5)
}
@@ -147,8 +173,18 @@ impl FontFace {
#[derive(Debug, Default)]
struct CatalogFamily {
name: String,
/// (style, file) of each face.
faces: Vec<(String, PathBuf)>,
faces: Vec<CatalogFace>,
}
/// One style of an installed family: a face, or a named instance of a variable font.
#[derive(Debug)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
struct CatalogFace {
style: String,
path: PathBuf,
/// Weight and italic as the style lists them (by its name, or a named instance's axes).
weight: f32,
italic: bool,
}
/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as
@@ -344,26 +380,103 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
Some((family, style))
}
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
/// One style a font file offers: one of its faces, or a named instance of a variable font
/// (Figtree's `Figtree[wght].ttf` holds Light, the default, and Regular to Black as instances).
#[derive(Clone, Debug)]
pub(crate) struct FaceStyle {
family: String,
style: String,
keys: FaceKeys,
/// A named instance's axis settings (user units); empty for the face itself.
variations: Vec<Variation>,
/// A named instance's weight (its `wght` setting); `None` for the face itself (its OS/2 table
/// says) or an instance of a font without a weight axis.
weight: Option<f32>,
/// A named instance is italic (by its `ital` or `slnt` setting, or its name).
italic: bool,
}
/// The styles of a face: the face itself, then its named instances when it is a variable font.
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
let Some((family, style)) = face_names(f) else { return vec![] };
let mut keys = FaceKeys::of(f, &family, &style);
let instances = named_instances(f, &family, &style, &mut keys);
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false };
std::iter::once(face).chain(instances).collect()
}
/// The named instances of a variable font face (its `fvar` table) as styles of the face's family,
/// named by their subfamily names, without the default instance (the face itself) and without
/// names already taken. The default instance's name, when it isn't the face's style name (a face
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
/// fewer instances or none; the counts read are capped.
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys) -> Vec<FaceStyle> {
let axes = f.axes();
if axes.is_empty() || axes.len() > MAX_AXES {
return vec![];
}
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
// Technical Note #5902), as PDF and `.ai` files name them.
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
let mut taken = vec![norm(style)];
let mut out = Vec::new();
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
continue;
}
let Some(style) = name(f, &[inst.subfamily_name_id()]) else { continue };
let ns = norm(&style);
if ns.is_empty() || taken.contains(&ns) {
continue;
}
// The default instance is the face itself.
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
keys.styles.push(ns.clone());
keys.postscript.push(norm(&format!("{prefix}{style}")));
taken.push(ns);
continue;
}
taken.push(ns);
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| t.to_be_bytes() == *tag).map(|(_, v)| *v);
let weight = axis(b"wght").map(|w| w.clamp(1.0, 1000.0));
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(f, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
let mut postscript: Vec<String> = inst.postscript_name_id().map(|id| all_names(f, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
postscript.push(norm(&format!("{prefix}{style}")));
for v in [&mut styles, &mut postscript] {
v.retain(|k| !k.is_empty());
v.sort();
v.dedup();
}
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic });
}
out
}
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
/// ones. Returns each style with the index of its face.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> {
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => 0,
};
(0..count)
.filter_map(|i| {
let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?;
Some((i, family, style))
})
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
.flat_map(|(i, f)| face_styles(&f).into_iter().map(move |s| (i, s)))
.collect()
}
/// (family, style, PostScript name) of every face in the font file at `path`, reading only its
/// table directories and `name` tables: a scan opens hundreds of font files, many of them
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
use std::io::{Read, Seek, SeekFrom};
/// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256;
@@ -390,29 +503,55 @@ fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
let dir = read_at(start.into(), 12)?;
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
let records = read_at(u64::from(start) + 12, tables * 16)?;
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
if len > MAX_NAME_TABLE {
return None;
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
if len > MAX_NAME_TABLE {
return None;
}
read_at(offset.into(), len as usize)
};
let name = table(b"name")?;
// A variable font's named instances are styles too.
let fvar = table(b"fvar");
// A font holding just these tables, to read them as the font itself would.
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
if let Some(fvar) = &fvar {
tables.push((b"fvar", fvar));
}
let table = read_at(offset.into(), len as usize)?;
// A one-table font holding just the `name` table, to read it as the font itself would.
let mut font = Vec::with_capacity(28 + table.len());
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
font.extend_from_slice(b"name");
for v in [0, 28, len] {
font.extend_from_slice(&u32::to_be_bytes(v));
}
font.extend_from_slice(&table);
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
let keys = FaceKeys::of(&f, &family, &style);
Some((family, style, keys))
let font = sfnt_of(&tables)?;
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
})
.flatten()
.collect()
}
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
#[cfg(any(test, not(target_arch = "wasm32")))]
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
let mut tables = tables.to_vec();
tables.sort_by_key(|t| *t.0);
let n = u16::try_from(tables.len()).ok()?;
let mut font = Vec::new();
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
for v in [n, 0, 0, 0] {
font.extend_from_slice(&v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
for (tag, data) in &tables {
font.extend_from_slice(*tag);
for v in [0, u32::try_from(offset).ok()?, u32::try_from(data.len()).ok()?] {
font.extend_from_slice(&v.to_be_bytes());
}
offset += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
font.extend_from_slice(data);
font.resize(font.len().next_multiple_of(4), 0);
}
Some(font)
}
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
pub fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
@@ -448,23 +587,31 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
dirs
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> Option<FontFace> {
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(Size::unscaled(), LocationRef::default());
let FaceStyle { family, style, keys, variations, weight, italic } = spec;
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
let m = f.metrics(Size::unscaled(), &location);
let a = f.attributes();
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
let keys = FaceKeys::of(&f, &family, &style);
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
let instance =
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
Some(FontFace {
keys,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
weight: a.weight.value(),
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
weight: weight.unwrap_or(a.weight.value()),
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
variations,
location,
instance,
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
@@ -523,8 +670,8 @@ impl FontDb {
// first: fallbacks for Japanese text after the requested and bundled fonts.
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
for (i, spec) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
faces.push(Arc::new(f));
}
}
@@ -631,9 +778,9 @@ impl FontDb {
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
for (style, _) in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) {
v.push((style_italic(style), style_weight(style), style.clone()));
for cf in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
v.push((cf.italic, cf.weight, cf.style.clone()));
}
}
}
@@ -652,11 +799,11 @@ impl FontDb {
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {
let data = Arc::new(bytes);
let mut added = 0;
for (i, family, style) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
for (i, spec) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) {
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
added += 1;
}
@@ -722,7 +869,7 @@ impl FontDb {
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
for (family, style, keys) in file_face_names(&p) {
for FaceStyle { family, style, keys, weight, italic, .. } in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
@@ -734,7 +881,8 @@ impl FontDb {
if entry.name.is_empty() {
entry.name = family;
}
entry.faces.push((style, p.clone()));
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
n += 1;
}
}
@@ -824,7 +972,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let mut paths: Vec<PathBuf> =
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default();
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|cf| cf.path.clone()).collect()).unwrap_or_default();
paths.sort();
paths.dedup();
let mut any = false;
@@ -867,7 +1015,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn is_cataloged(&self, family: &str, style: &str) -> bool {
let ns = norm(style);
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns))
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|cf| norm(&cf.style) == ns))
}
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
@@ -901,6 +1049,11 @@ impl FontDb {
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
// Another of a face's style names: in another language, or a variable font's default
// instance's name.
if let Some(f) = cands.iter().find(|f| f.keys.styles.contains(&ns)) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
@@ -958,7 +1111,7 @@ impl FontDb {
return true;
}
}
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect();
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect();
paths.sort();
paths.dedup();
for p in paths {
@@ -966,8 +1119,7 @@ impl FontDb {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit =
enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
let hit = enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
return true;
}
@@ -986,7 +1138,7 @@ impl FontDb {
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
+4
View File
@@ -18,6 +18,8 @@ mod fontdb;
pub mod hyphen;
mod layout;
mod shape;
#[cfg(any(test, feature = "test-fonts"))]
pub mod test_fonts;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
@@ -385,4 +387,6 @@ mod tests_sysfonts;
#[cfg(test)]
mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_vertical;
+3 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::instance::Size;
use vectorcraft_doc::CharStyle;
use crate::features::OtFeatures;
@@ -247,7 +247,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).build();
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
@@ -271,7 +271,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
// Fallback: nominal glyphs and hmtx advances, no shaping.
if let Some(f) = face.skrifa() {
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
+112
View File
@@ -0,0 +1,112 @@
//! Fonts built in code for tests, so no font file is committed: a small variable font (#296).
//! Compiled for this crate's tests and, with the `test-fonts` feature, for other crates' tests.
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use skrifa::raw::tables::glyf::Glyph;
use skrifa::{GlyphId, MetadataProvider};
use write_fonts::FontBuilder;
use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};
use write_fonts::tables::gvar::{GlyphDelta, GlyphDeltas, GlyphVariations, Gvar, Tent};
use write_fonts::types::{F2Dot14, Fixed, NameId, Tag};
/// The family of [`variable_font`].
pub const VARIABLE_FAMILY: &str = "Varitest Sans";
/// The characters [`variable_font`] widens towards its heaviest weight (simple glyphs in Source Sans
/// 3; the composite ones, like `H`, would need their components varied).
pub const VARIABLE_CHARS: &str = "lIn";
/// How much wider [`VARIABLE_CHARS`] are at the heaviest weight (outlines and advances).
pub const VARIABLE_WIDEN: f32 = 0.25;
/// The bundled Source Sans 3 Regular as a variable font of family [`VARIABLE_FAMILY`]: one weight
/// axis (400–700, default 400, the face itself being Regular) whose maximum makes the glyphs of
/// [`VARIABLE_CHARS`] [`VARIABLE_WIDEN`] wider (outlines and advances, through `gvar`), with the
/// named instances Normal (the default, the face Regular), SemiBold (550) and Bold (700), plus an instance without
/// a name and one named like another (`BOLD`), which style lists leave out. `None` when it can't
/// be built.
pub fn variable_font() -> Option<Vec<u8>> {
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let (glyf, loca) = (font.glyf().ok()?, font.loca(None).ok()?);
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
let charmap = font.charmap();
let widened: Vec<GlyphId> = VARIABLE_CHARS.chars().filter_map(|c| charmap.map(c)).collect();
let mut variations = Vec::new();
for g in 0..u32::from(font.maxp().ok()?.num_glyphs()) {
let gid = write_fonts::types::GlyphId::new(g);
let id = GlyphId::new(g);
let deltas = if widened.contains(&id) {
let Ok(Some(Glyph::Simple(s))) = loca.get_glyf(id, &glyf) else { return None };
let wider = |x: f32| (x * VARIABLE_WIDEN).round() as i16;
let mut d: Vec<GlyphDelta> = s.points().map(|p| GlyphDelta::required(wider(f32::from(p.x)), 0)).collect();
// The phantom points: left side bearing, advance, top, bottom.
let advance = wider(metrics.advance_width(id)?);
d.extend([(0, 0), (advance, 0), (0, 0), (0, 0)].map(|(x, y)| GlyphDelta::required(x, y)));
vec![GlyphDeltas::new(vec![Tent::new(F2Dot14::from_f32(1.0), None)], d)]
} else {
vec![]
};
variations.push(GlyphVariations::new(gid, deltas));
}
let gvar = Gvar::new(variations, 1).ok()?;
let axis = VariationAxisRecord {
axis_tag: Tag::new(b"wght"),
min_value: Fixed::from_i32(400),
default_value: Fixed::from_i32(400),
max_value: Fixed::from_i32(700),
flags: 0,
axis_name_id: NameId::new(256),
};
let instance = |name: u16, wght: i32| InstanceRecord {
subfamily_name_id: NameId::new(name),
flags: 0,
coordinates: vec![Fixed::from_i32(wght)],
post_script_name_id: None,
};
let instances = vec![instance(257, 400), instance(258, 550), instance(259, 700), instance(260, 600), instance(261, 650)];
let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], instances));
let name = name_table(&[
(1, VARIABLE_FAMILY),
(2, "Regular"),
(4, "Varitest Sans Regular"),
(6, "VaritestSans-Regular"),
(256, "Weight"),
(257, "Normal"),
(258, "SemiBold"),
(259, "Bold"),
(261, "BOLD"),
])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
builder.add_table(&fvar).ok()?.add_table(&gvar).ok()?;
// The rest of Source Sans 3's tables as they are.
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// A `name` table of Windows English (US) records (name id, string).
pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
let mut records = records.to_vec();
records.sort_by_key(|r| r.0);
let n = u16::try_from(records.len()).ok()?;
let (mut head, mut strings) = (Vec::new(), Vec::new());
for v in [0, n, 6 + 12 * n] {
head.extend_from_slice(&v.to_be_bytes());
}
for (id, s) in records {
let bytes: Vec<u8> = s.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, 0x0409, id, u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?] {
head.extend_from_slice(&v.to_be_bytes());
}
strings.extend(bytes);
}
head.extend(strings);
Some(head)
}
+248
View File
@@ -0,0 +1,248 @@
//! Variable fonts: each named instance is a style of its own, listed, resolved, shaped, drawn and
//! embedded at its axis settings (#296: Figtree listed only Light, its default instance).
use kurbo::{Point, Shape};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font};
use super::*;
fn font() -> Vec<u8> {
variable_font().expect("the test font builds")
}
/// A database of the bundled fonts and the test variable font.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(font()), 3, "Regular, SemiBold and Bold");
db
}
/// The width of `c`'s outline in font units.
fn ink_width(db: &FontDb, face: &FontFace, c: char) -> f64 {
db.outline(face, face.glyph_for(c)).bounding_box().width()
}
fn text(family: &str, style: &str, s: &str) -> TextObject {
TextObject::point(Point::ZERO, s, CharStyle { font_family: family.into(), font_style: style.into(), size: 100.0, ..CharStyle::default() })
}
#[test]
fn named_instances_are_styles_of_the_family() {
let db = db();
// The default instance is the face itself; the unnamed and the twice-named ones are left out.
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.family.as_str(), bold.style.as_str(), bold.weight, bold.italic), (FAMILY, "Bold", 700.0, false));
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
let regular = db.face(FAMILY, "Regular").unwrap();
assert!(regular.variations().is_empty() && regular.weight == 400.0);
assert_eq!(db.face(FAMILY, "SemiBold").unwrap().weight, 550.0);
// Fonts added again add nothing.
assert_eq!(db.add_font(font()), 0);
}
#[test]
fn an_instance_is_found_by_its_names_and_by_weight() {
let db = db();
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.style.clone(), m)).unwrap();
assert_eq!(m(FAMILY, "Bold"), ("Bold".into(), FontMatch::Exact));
assert_eq!(m("varitest sans", "semi bold"), ("SemiBold".into(), FontMatch::Exact), "names are normalized");
// The default instance's own name names the face.
assert_eq!(m(FAMILY, "Normal"), ("Regular".into(), FontMatch::Exact));
assert!(!db.styles(FAMILY).contains(&"Normal".to_string()));
// A style the font hasn't: the closest weight.
assert_eq!(m(FAMILY, "Black"), ("Bold".into(), FontMatch::Style));
assert_eq!(m(FAMILY, "Medium"), ("SemiBold".into(), FontMatch::Style));
// PostScript names (as PDF and .ai files name fonts): `<family>-<style>` without spaces.
assert_eq!(m("VaritestSans-Bold", "Regular"), ("Bold".into(), FontMatch::Exact));
assert_eq!(db.find_postscript("VaritestSans-SemiBold").unwrap().style, "SemiBold");
}
#[test]
fn an_instance_draws_and_measures_its_own_glyphs() {
let db = db();
let (regular, semi, bold) = (db.face(FAMILY, "Regular").unwrap(), db.face(FAMILY, "SemiBold").unwrap(), db.face(FAMILY, "Bold").unwrap());
let widen = f64::from(VARIABLE_WIDEN);
for c in VARIABLE_CHARS.chars() {
let g = regular.glyph_for(c);
assert_eq!(bold.glyph_for(c), g);
// Advances (gvar phantom points) and outlines: a quarter wider at Bold, half that at SemiBold.
let (r, b, s) = (regular.advance(g), bold.advance(g), semi.advance(g));
assert!((b - r * (1.0 + widen)).abs() <= 1.0, "{c}: advance {r} -> {b}");
assert!((s - r * (1.0 + widen / 2.0)).abs() <= 1.0, "{c}: SemiBold advance {s}");
let (ri, bi) = (ink_width(&db, &regular, c), ink_width(&db, &bold, c));
assert!((bi - ri * (1.0 + widen)).abs() <= 2.0, "{c}: outline {ri} -> {bi}");
}
// Characters the axis doesn't change stay as they are.
let o = regular.glyph_for('o');
assert_eq!(regular.advance(o), bold.advance(o));
assert_eq!(db.outline(&regular, o).to_svg(), db.outline(&bold, o).to_svg());
// Laid out (shaped with the instance): the glyphs come from the Bold face, advance as it does
// and are drawn with its outlines (what the canvas, Create Outlines and hit tests use).
let (lr, lb) = (layout(&db, &text(FAMILY, "Regular", VARIABLE_CHARS)), layout(&db, &text(FAMILY, "Bold", VARIABLE_CHARS)));
assert!(lb.glyphs.iter().all(|g| g.font_id == bold.id()));
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
let expected: f64 = VARIABLE_CHARS.chars().map(|c| bold.advance(bold.glyph_for(c))).sum::<f64>() * 100.0 / bold.units_per_em();
assert!((width(&lb) - expected).abs() < 0.5, "shaped {} vs {expected}", width(&lb));
assert!(width(&lb) > width(&lr) * (1.0 + widen * 0.9));
let ink = |l: &TextLayout| l.to_bezpath().bounding_box().width();
assert!(ink(&lb) > ink(&lr) * (1.0 + widen * 0.9), "{} vs {}", ink(&lb), ink(&lr));
}
#[test]
fn an_embedded_instance_is_a_static_font_of_that_instance() {
let db = db();
let bold = db.face(FAMILY, "Bold").unwrap();
let e = bold.embed(&['l', 'o']).unwrap();
assert!(e.subset && !e.cff);
let f = skrifa::FontRef::new(&e.data).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
let g = f.charmap().map('l').unwrap();
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(g).unwrap();
assert!((f64::from(adv) - bold.advance(bold.glyph_for('l'))).abs() <= 1.0, "the Bold advance: {adv}");
// The default face embeds as before.
let regular = db.face(FAMILY, "Regular").unwrap();
let data = regular.embed(&['l']).unwrap().data;
let r = skrifa::FontRef::new(&data).unwrap();
let rl = r.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(r.charmap().map('l').unwrap()).unwrap();
assert!((f64::from(rl) - regular.advance(regular.glyph_for('l'))).abs() <= 1.0 && rl < adv, "{rl} vs {adv}");
}
#[test]
fn installed_variable_fonts_list_their_instances_before_loading() {
let dir = std::env::temp_dir().join(format!("vc-varfonts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Varitest[wght].ttf"), font()).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
assert!(!db.is_loaded(FAMILY), "listed from the name and fvar tables alone");
assert_eq!(db.by_postscript_name("VaritestSans-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
let (bold, m) = db.resolve(FAMILY, "Bold").unwrap();
assert_eq!((bold.style.as_str(), m), ("Bold", FontMatch::Exact));
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
assert!(bold.path().is_some());
}
/// Damaged or hostile `fvar` tables give fewer styles, never a panic, and the face still works.
#[test]
fn damaged_variation_tables_never_panic() {
let name = name_table(&[(1, "Broken Var"), (2, "Regular"), (256, "W")]).unwrap();
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
// Header: version 1.0, axes offset 16, reserved, axis count, axis size 20, instance count,
// instance size.
let header = |axes: u16, axis_size: u16, instances: u16, instance_size: u16| {
let mut h = be(&[0x0001_0000]);
for v in [16, 2, axes, axis_size, instances, instance_size] {
h.extend(v.to_be_bytes());
}
h
};
let axis = |tag: &[u8; 4], min: i32, def: i32, max: i32| {
let mut a = tag.to_vec();
a.extend(be(&[(min << 16) as u32, (def << 16) as u32, (max << 16) as u32]));
a.extend([0, 0, 1, 0]);
a
};
let mut damaged: Vec<Vec<u8>> = vec![
vec![],
vec![0; 3],
header(0xffff, 20, 0xffff, 8),
[header(1, 20, 0xffff, 8), axis(b"wght", 100, 400, 900)].concat(),
[header(1, 20, 3, 0xffff), axis(b"wght", 100, 400, 900)].concat(),
[header(1, 0, 3, 8), axis(b"wght", 100, 400, 900)].concat(),
[header(200, 20, 1, 804), vec![0; 4000]].concat(),
];
// Many instances (all named): the list is capped.
let mut many = [header(1, 20, 2000, 8), axis(b"wght", 0, 0, 30000)].concat();
for i in 0..2000u32 {
many.extend(be(&[256 << 16, (i + 1) << 16]));
}
damaged.push(many);
for fvar in &damaged {
let f = fontdb::sfnt_of(&[(b"name", &name), (b"fvar", fvar)]).unwrap();
let styles = fontdb::face_styles(&skrifa::FontRef::new(&f).unwrap());
assert!(!styles.is_empty() && styles.len() <= 2, "{} styles", styles.len());
}
// A real font with a damaged fvar (and its gvar) still loads and lays out.
let good = font();
let at = |tag: &[u8; 4]| {
let tables = u16::from_be_bytes([good[4], good[5]]) as usize;
let r = (0..tables).map(|i| 12 + 16 * i).find(|r| &good[*r..*r + 4] == tag).unwrap();
let off = u32::from_be_bytes(good[r + 8..r + 12].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(good[r + 12..r + 16].try_into().unwrap()) as usize;
off..off + len
};
let (fvar, gvar) = (at(b"fvar"), at(b"gvar"));
// Deterministic byte damage across both tables.
let mut seed = 0x2545_f491_u32;
for round in 0..300 {
let mut bad = good.clone();
for _ in 0..1 + round % 8 {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let range = if seed.is_multiple_of(2) { fvar.clone() } else { gvar.clone() };
let i = range.start + (seed as usize >> 3) % range.len();
bad[i] = (seed >> 24) as u8;
}
let db = FontDb::with_font_dirs(vec![]);
db.add_font(bad);
for style in db.styles(FAMILY) {
let l = layout(&db, &text(FAMILY, &style, "lHio"));
assert!(l.glyphs.len() <= 8, "{style}: {} glyphs", l.glyphs.len());
}
}
}
/// Windows ships variable system fonts: Bahnschrift (weight and width axes) lists its named
/// instances and draws them. Skipped where it isn't installed.
#[test]
fn bahnschrift_lists_and_draws_its_instances() {
let db = FontDb::with_font_dirs(system_font_dirs());
if !db.has_family("Bahnschrift") {
eprintln!("skipped: Bahnschrift is not installed");
return;
}
let styles = db.styles("Bahnschrift");
eprintln!("Bahnschrift: {styles:?}");
for s in ["Light", "Regular", "SemiBold", "Bold"] {
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
}
let light = db.face("Bahnschrift", "Light").unwrap();
let bold = db.face("Bahnschrift", "Bold").unwrap();
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light", "Bold"));
assert!(bold.weight > light.weight, "{} vs {}", bold.weight, light.weight);
let (lw, bw) = (ink_width(&db, &light, 'l'), ink_width(&db, &bold, 'l'));
assert!(bw > lw * 1.05, "the Bold l is wider: {lw} -> {bw}");
let w = |s: &str| layout(&db, &text("Bahnschrift", s, "Hamburgefonstiv")).to_bezpath().bounding_box().width();
assert!(w("Bold") > w("Light"), "{} vs {}", w("Bold"), w("Light"));
}
/// Segoe UI Variable (Windows 11; optical size and weight axes). Skipped where it isn't installed.
#[test]
fn segoe_ui_variable_lists_its_instances() {
let db = FontDb::with_font_dirs(system_font_dirs());
let families: Vec<String> = db.families().into_iter().filter(|f| f.starts_with("Segoe UI Variable")).collect();
if families.is_empty() {
eprintln!("skipped: Segoe UI Variable is not installed");
return;
}
for f in &families {
let styles = db.styles(f);
eprintln!("{f}: {styles:?}");
assert!(styles.len() > 1, "{f}: {styles:?}");
}
if families.iter().any(|f| f == "Segoe UI Variable") {
let (light, bold) = (db.face("Segoe UI Variable", "Light Text").unwrap(), db.face("Segoe UI Variable", "Bold Text").unwrap());
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light Text", "Bold Text"));
assert!(bold.weight > light.weight && !bold.variations().is_empty());
let w = |f: &FontFace| ink_width(&db, f, 'm');
assert!(w(&bold) > w(&light), "{} vs {}", w(&bold), w(&light));
}
}
+4 -3
View File
@@ -93,14 +93,15 @@ impl CornerDrag {
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
}
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
/// shape's own units), from square to fully round.
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's inward diagonal (in the shape's own units), from square to fully round.
fn radius_at(&self, p: Point) -> f64 {
let w = &self.widgets;
let inv = w.xf.inverse();
let d = inv * p - inv * self.start;
let (_, (sx, sy)) = CORNERS[self.corner];
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
+15
View File
@@ -274,6 +274,21 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
#[test]
fn menu_parameter_dialogs_are_compact() {
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
let wide = dialog_rect(&mut app, 1600.0);
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
}
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]
+129 -1
View File
@@ -2343,14 +2343,16 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let tree = menu_tree();
let end = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
})
.inner;
@@ -2360,6 +2362,39 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
end
}
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
/// not switch to the title under it).
fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
/// it scrolls when it is taller than the window.
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
@@ -2954,6 +2989,99 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
mod tests {
use super::*;
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
/// (rect, id of the title's popup).
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let mut layer = None;
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
layer = Some(ui.layer_id());
menu_bar(app, ui);
});
out.textures_delta.clear();
let layer = layer.unwrap();
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(layer)
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
// `egui::Popup::default_response_id` of the title's response.
.map(|w| (w.rect, w.id.with("popup")))
.collect()
});
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
titles
}
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
bar_frame(&mut app, &ctx, vec![]);
let titles = bar_frame(&mut app, &ctx, vec![]);
assert_eq!(titles.len(), menu_tree().len());
// 0 is the app menu, then File, Edit, Object.
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut t = bar_frame(app, &ctx, events);
for _ in 0..2 {
t = bar_frame(app, &ctx, vec![]);
}
t
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// Nothing open: hovering a title opens nothing.
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert!(open_titles(&ctx, &t).is_empty());
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
frames(&mut app, vec![button(file, true)]);
let t = frames(&mut app, vec![button(file, false)]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
// stays open while the pointer stays still over Object.
egui::Popup::open_id(&ctx, t[1].1);
let t = frames(&mut app, vec![]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
}
#[test]
fn a_popup_covering_a_title_does_not_switch_menus() {
let ctx = egui::Context::default();
let p = egui::pos2(90.0, 12.0);
let mut reaches = (true, false);
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
for _ in 0..3 {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
let ctx = ui.ctx().clone();
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
let q = p + egui::vec2(0.0, 100.0);
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
ui.allocate_space(egui::vec2(20.0, 20.0));
});
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
});
out.textures_delta.clear();
}
assert_eq!(reaches, (false, true));
}
#[test]
fn names_in_menus_are_not_interface_labels() {
for (id, label) in [