mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #326 from storytold/fix/issue-296
Fix #296: variable fonts list and draw every named style
This commit is contained in:
14 files changed
+679
-73
No files matched your search
Generated
+1
@@ -4553,6 +4553,7 @@ dependencies = [
|
||||
"subsetter",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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] };
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ®ular, 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(®ular, 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user