mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
M4.97: SVG Options choose the encoding, SVG Tiny 1.2 and embedded font subsets
SVG export writes UTF-8, UTF-16 (big-endian with a byte order mark) or ISO 8859-1 (other characters as character references) and SVG/SVGZ open in any of them. The Tiny 1.2 profile (simplified) writes presentation attributes only and leaves out filters, masks, blend modes and symbols with warnings. embedFonts embeds the fonts type uses as @font-face subsets of the characters used, honouring the fonts' fsType (whole when subsetting is forbidden, left out when embedding is). New options: encoding, profile, embedFonts (SVG Options dialog, document.formats); document.serialize also returns dataBase64 for non-UTF-8 files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
eacc83e05b
commit
fa3dcab411
@@ -0,0 +1,238 @@
|
||||
//! Fonts carried by exported documents (SVG `@font-face`): what a font's licence allows (its
|
||||
//! OS/2 `fsType` flags) and font files holding only the characters a document uses.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::types::Tag;
|
||||
|
||||
use crate::FontFace;
|
||||
|
||||
/// What a font's licence lets a document do with it (OS/2 `fsType`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Embedding {
|
||||
/// The font may be embedded, as a subset.
|
||||
Subset,
|
||||
/// The font may be embedded whole only (no subsetting).
|
||||
Whole,
|
||||
/// Restricted licence, or bitmap embedding only: the outlines may not be embedded.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
impl Embedding {
|
||||
/// The permission an OS/2 `fsType` value grants. Bits 0–3 are the usage permissions (none:
|
||||
/// installable; 2: restricted; 4: preview and print; 8: editable), the most permissive one
|
||||
/// winning when several are set; bit 8 forbids subsetting and bit 9 allows bitmaps only.
|
||||
pub fn from_fs_type(fs: u16) -> Self {
|
||||
let restricted = fs & 0x0002 != 0 && fs & 0x000c == 0;
|
||||
if restricted || fs & 0x0200 != 0 {
|
||||
Self::Forbidden
|
||||
} else if fs & 0x0100 != 0 {
|
||||
Self::Whole
|
||||
} else {
|
||||
Self::Subset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A font file a document carries.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmbeddedFont {
|
||||
/// An OpenType file of the one face (`cmap`, `OS/2` and the other tables a viewer needs).
|
||||
pub data: Vec<u8>,
|
||||
/// CFF outlines (`font/otf`) rather than TrueType ones (`font/ttf`).
|
||||
pub cff: bool,
|
||||
/// Only the glyphs of the characters asked for; false: the whole face, as its licence asks
|
||||
/// (or when it can't be subset).
|
||||
pub subset: bool,
|
||||
}
|
||||
|
||||
const CMAP: Tag = Tag::new(b"cmap");
|
||||
const OS2: Tag = Tag::new(b"OS/2");
|
||||
const HEAD: Tag = Tag::new(b"head");
|
||||
|
||||
impl FontFace {
|
||||
/// What the face's licence allows (a face without an `OS/2` table states no restriction).
|
||||
pub fn embedding(&self) -> Embedding {
|
||||
self.skrifa().and_then(|f| f.os2().ok()).map_or(Embedding::Subset, |os2| Embedding::from_fs_type(os2.fs_type()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 });
|
||||
match self.embedding() {
|
||||
Embedding::Forbidden => None,
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every table of `font`, by tag.
|
||||
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>> {
|
||||
let charmap = font.charmap();
|
||||
let mut remapper = subsetter::GlyphRemapper::new();
|
||||
let mut map: Vec<(u32, u16)> = chars
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let gid = u16::try_from(charmap.map(*c)?.to_u32()).ok().filter(|g| *g != 0)?;
|
||||
Some((*c as u32, remapper.remap(gid)))
|
||||
})
|
||||
.collect();
|
||||
map.sort_unstable();
|
||||
map.dedup_by_key(|m| m.0);
|
||||
let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?;
|
||||
let sub = skrifa::FontRef::new(&data).ok()?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// The face's `OS/2` table with its first and last character fitted to the subset's.
|
||||
fn os2_for(os2: &[u8], map: &[(u32, u16)]) -> Vec<u8> {
|
||||
let mut out = os2.to_vec();
|
||||
let bmp = |c: u32| u16::try_from(c).unwrap_or(u16::MAX).to_be_bytes();
|
||||
if let (Some(first), Some(last), Some(slot)) = (map.first(), map.last(), out.get_mut(64..68)) {
|
||||
slot[..2].copy_from_slice(&bmp(first.0));
|
||||
slot[2..].copy_from_slice(&bmp(last.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs of consecutive characters mapped to consecutive glyphs: (first char, last char, first
|
||||
/// glyph).
|
||||
fn runs(map: &[(u32, u16)]) -> Vec<(u32, u32, u16)> {
|
||||
let mut out: Vec<(u32, u32, u16)> = Vec::new();
|
||||
for &(c, g) in map {
|
||||
match out.last_mut() {
|
||||
Some((start, end, g0)) if c == *end + 1 && u32::from(g) == u32::from(*g0) + (c - *start) => *end = c,
|
||||
_ => out.push((c, c, g)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `cmap` table mapping `map`'s characters (sorted, unique) to their glyphs: a format 4
|
||||
/// subtable for the Basic Multilingual Plane and, when characters lie beyond it (or it would be
|
||||
/// too long), a format 12 one for all of them. `None` when there is nothing to map.
|
||||
pub(crate) fn cmap(map: &[(u32, u16)]) -> Option<Vec<u8>> {
|
||||
let all = runs(map);
|
||||
let last = all.last()?.1;
|
||||
let mut bmp: Vec<(u32, u32, u16)> = runs(&map.iter().copied().filter(|(c, _)| *c < 0xffff).collect::<Vec<_>>());
|
||||
// The closing segment every format 4 subtable ends with.
|
||||
bmp.push((0xffff, 0xffff, 0));
|
||||
let f4_len = 16 + 8 * bmp.len();
|
||||
let f4 = f4_len <= usize::from(u16::MAX);
|
||||
let f12 = !f4 || last >= 0xffff;
|
||||
let mut subtables: Vec<(u16, Vec<u8>)> = Vec::new();
|
||||
if f4 {
|
||||
let seg_x2 = u16::try_from(bmp.len() * 2).ok()?;
|
||||
let sel = bmp.len().ilog2() as u16;
|
||||
let range = 2u16.checked_pow(u32::from(sel))?.checked_mul(2)?;
|
||||
let mut s = Vec::with_capacity(f4_len);
|
||||
for v in [4, u16::try_from(f4_len).ok()?, 0, seg_x2, range, sel, seg_x2 - range] {
|
||||
s.extend(v.to_be_bytes());
|
||||
}
|
||||
let u = |c: u32| u16::try_from(c).unwrap_or(u16::MAX);
|
||||
s.extend(bmp.iter().flat_map(|(_, end, _)| u(*end).to_be_bytes()));
|
||||
s.extend(0u16.to_be_bytes());
|
||||
s.extend(bmp.iter().flat_map(|(start, _, _)| u(*start).to_be_bytes()));
|
||||
// The closing segment maps to glyph 0: 0xffff + 1.
|
||||
s.extend(bmp.iter().flat_map(|(start, end, g)| if *end == 0xffff { 1u16 } else { g.wrapping_sub(u(*start)) }.to_be_bytes()));
|
||||
s.extend(bmp.iter().flat_map(|_| 0u16.to_be_bytes()));
|
||||
subtables.push((1, s));
|
||||
}
|
||||
if f12 {
|
||||
let n = u32::try_from(all.len()).ok()?;
|
||||
let mut s = Vec::with_capacity(16 + 12 * all.len());
|
||||
s.extend(12u16.to_be_bytes());
|
||||
s.extend(0u16.to_be_bytes());
|
||||
for v in [16 + 12 * n, 0, n] {
|
||||
s.extend(v.to_be_bytes());
|
||||
}
|
||||
for (start, end, g) in &all {
|
||||
for v in [*start, *end, u32::from(*g)] {
|
||||
s.extend(v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
subtables.push((10, s));
|
||||
}
|
||||
// Header and encoding records (Windows platform: 1 = BMP, 10 = full repertoire).
|
||||
let mut out = Vec::new();
|
||||
out.extend(0u16.to_be_bytes());
|
||||
out.extend(u16::try_from(subtables.len()).ok()?.to_be_bytes());
|
||||
let mut offset = 4 + 8 * subtables.len();
|
||||
for (encoding, s) in &subtables {
|
||||
out.extend(3u16.to_be_bytes());
|
||||
out.extend(encoding.to_be_bytes());
|
||||
out.extend(u32::try_from(offset).ok()?.to_be_bytes());
|
||||
offset += s.len();
|
||||
}
|
||||
for (_, s) in subtables {
|
||||
out.extend(s);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The sum of `data` as big-endian 32-bit words (zero-padded), as table directories record it.
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
let (words, rest) = data.as_chunks::<4>();
|
||||
let mut last = [0u8; 4];
|
||||
last[..rest.len()].copy_from_slice(rest);
|
||||
words.iter().chain([&last]).fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes(*w)))
|
||||
}
|
||||
|
||||
/// An OpenType file of `tables` (unique tags) with checksums and the `head` checksum adjustment.
|
||||
fn sfnt(version: u32, mut tables: Vec<(Tag, Cow<'_, [u8]>)>) -> Option<Vec<u8>> {
|
||||
tables.sort_by_key(|t| t.0);
|
||||
tables.dedup_by_key(|t| t.0);
|
||||
let n = u16::try_from(tables.len()).ok().filter(|n| *n > 0)?;
|
||||
let sel = n.ilog2() as u16;
|
||||
let range = 2u16.checked_pow(u32::from(sel))?.checked_mul(16)?;
|
||||
let mut out = Vec::with_capacity(12 + 16 * tables.len() + tables.iter().map(|t| t.1.len() + 3).sum::<usize>());
|
||||
out.extend(version.to_be_bytes());
|
||||
for v in [n, range, sel, n.checked_mul(16)? - range] {
|
||||
out.extend(v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
let mut head_at = None;
|
||||
for (tag, data) in &mut tables {
|
||||
if *tag == HEAD {
|
||||
// The adjustment is summed as zero; it is set once the whole file is written.
|
||||
data.to_mut().get_mut(8..12)?.fill(0);
|
||||
head_at = Some(offset + 8);
|
||||
}
|
||||
out.extend(tag.to_be_bytes());
|
||||
out.extend(checksum(data).to_be_bytes());
|
||||
out.extend(u32::try_from(offset).ok()?.to_be_bytes());
|
||||
out.extend(u32::try_from(data.len()).ok()?.to_be_bytes());
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
out.extend_from_slice(data);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
if let Some(at) = head_at {
|
||||
let adjust = 0xb1b0_afba_u32.wrapping_sub(checksum(&out));
|
||||
out.get_mut(at..at + 4)?.copy_from_slice(&adjust.to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -77,6 +77,10 @@ impl FontFace {
|
||||
pub(crate) fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// The face's index in its font file (collections hold several).
|
||||
pub(crate) fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
/// Unique id of this face within the process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
@@ -243,8 +247,8 @@ fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
fn style_weight(style: &str) -> f32 {
|
||||
/// Weight implied by a style name (400 = regular).
|
||||
pub fn style_weight(style: &str) -> f32 {
|
||||
let s = norm(style);
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("extralight", 200.0),
|
||||
@@ -441,6 +445,11 @@ impl FontDb {
|
||||
self.find(FALLBACK_FAMILY, style).or_else(|| self.find(FALLBACK_FAMILY, "Regular")).or_else(|| self.read_faces().first().cloned())
|
||||
}
|
||||
|
||||
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
|
||||
pub fn face_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
|
||||
self.read_faces().iter().find(|f| f.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod composer;
|
||||
pub mod edit;
|
||||
pub mod embed;
|
||||
mod features;
|
||||
mod fontdb;
|
||||
pub mod hyphen;
|
||||
@@ -19,7 +20,7 @@ mod shape;
|
||||
pub mod thread;
|
||||
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
@@ -353,6 +354,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_embed;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_snap;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Embedded fonts: licence flags and subsets a viewer can use.
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::raw::types::Tag;
|
||||
use skrifa::raw::{FontData, FontRead, TableProvider};
|
||||
|
||||
use super::embed::Embedding;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fs_type_flags_decide_the_embedding() {
|
||||
assert_eq!(Embedding::from_fs_type(0), Embedding::Subset, "installable");
|
||||
assert_eq!(Embedding::from_fs_type(0x0004), Embedding::Subset, "preview & print");
|
||||
assert_eq!(Embedding::from_fs_type(0x0008), Embedding::Subset, "editable");
|
||||
assert_eq!(Embedding::from_fs_type(0x0002), Embedding::Forbidden, "restricted");
|
||||
assert_eq!(Embedding::from_fs_type(0x0006), Embedding::Subset, "the most permissive bit wins");
|
||||
assert_eq!(Embedding::from_fs_type(0x0200), Embedding::Forbidden, "bitmaps only");
|
||||
assert_eq!(Embedding::from_fs_type(0x0108), Embedding::Whole, "no subsetting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subset_holds_only_the_glyphs_used_and_maps_their_characters() {
|
||||
let face = FontDb::global().face("Source Sans 3", "Regular").unwrap();
|
||||
assert_eq!(face.embedding(), Embedding::Subset);
|
||||
let e = face.embed(&['H', 'e', 'l', 'l', 'o', ' ']).unwrap();
|
||||
assert!(e.subset && !e.cff);
|
||||
assert!(e.data.len() * 20 < face.data().len(), "{} of {} bytes", e.data.len(), face.data().len());
|
||||
let f = skrifa::FontRef::new(&e.data).unwrap();
|
||||
// .notdef and the five characters' glyphs.
|
||||
assert_eq!(f.maxp().unwrap().num_glyphs(), 6);
|
||||
let map = f.charmap();
|
||||
let metrics = f.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
for c in ['H', 'e', 'l', 'o', ' '] {
|
||||
let g = map.map(c).unwrap_or_else(|| panic!("{c} mapped"));
|
||||
assert_eq!(metrics.advance_width(g).unwrap() as f64, face.advance(face.glyph_for(c)), "{c}: same advance");
|
||||
}
|
||||
assert!(map.map('x').is_none(), "unused characters are left out");
|
||||
let h = f.outline_glyphs().get(map.map('H').unwrap()).unwrap();
|
||||
let mut pen = Commands::default();
|
||||
h.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).unwrap();
|
||||
assert!(pen.0 > 4, "H keeps its outline");
|
||||
// The tables a web viewer needs, the licence flags kept and the file checksum right.
|
||||
for t in [b"cmap", b"OS/2", b"head", b"hhea", b"hmtx", b"maxp", b"name", b"post", b"glyf", b"loca"] {
|
||||
assert!(f.table_data(Tag::new(t)).is_some(), "{}", String::from_utf8_lossy(t));
|
||||
}
|
||||
let os2 = f.os2().unwrap();
|
||||
assert_eq!(os2.fs_type(), face.skrifa().unwrap().os2().unwrap().fs_type());
|
||||
assert_eq!((os2.us_first_char_index(), os2.us_last_char_index()), (' ' as u16, 'o' as u16));
|
||||
let (words, rest) = e.data.as_chunks::<4>();
|
||||
assert!(rest.is_empty());
|
||||
assert_eq!(words.iter().fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes(*w))), 0xb1b0_afba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmaps_map_runs_and_characters_beyond_the_basic_plane() {
|
||||
let face = FontDb::global().face("Inter", "Regular").unwrap();
|
||||
let e = face.embed(&['A', 'B', 'z']).unwrap();
|
||||
let f = skrifa::FontRef::new(&e.data).unwrap();
|
||||
let cmap = f.cmap().unwrap();
|
||||
let (a, b, z) = (cmap.map_codepoint('A').unwrap(), cmap.map_codepoint('B').unwrap(), cmap.map_codepoint('z').unwrap());
|
||||
assert!(a != b && b != z && a.to_u32() != 0);
|
||||
assert_eq!(cmap.map_codepoint('C'), None);
|
||||
// No bundled face maps characters beyond the BMP: the table is checked on its own.
|
||||
let raw = embed::cmap(&[(0x41, 1), (0x42, 2), (0x1f600, 3)]).unwrap();
|
||||
let cmap = skrifa::raw::tables::cmap::Cmap::read(FontData::new(&raw)).unwrap();
|
||||
assert_eq!(cmap.encoding_records().len(), 2, "format 4 and format 12");
|
||||
for (c, g) in [('A', 1), ('B', 2), ('\u{1f600}', 3)] {
|
||||
assert_eq!(cmap.map_codepoint(c).map(|g| g.to_u32()), Some(g), "{c}");
|
||||
}
|
||||
assert_eq!(cmap.map_codepoint('C'), None);
|
||||
assert!(embed::cmap(&[]).is_none());
|
||||
}
|
||||
|
||||
/// Counts outline commands.
|
||||
#[derive(Default)]
|
||||
struct Commands(usize);
|
||||
|
||||
impl skrifa::outline::OutlinePen for Commands {
|
||||
fn move_to(&mut self, _: f32, _: f32) {
|
||||
self.0 += 1;
|
||||
}
|
||||
fn line_to(&mut self, _: f32, _: f32) {
|
||||
self.0 += 1;
|
||||
}
|
||||
fn quad_to(&mut self, _: f32, _: f32, _: f32, _: f32) {
|
||||
self.0 += 1;
|
||||
}
|
||||
fn curve_to(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32) {
|
||||
self.0 += 1;
|
||||
}
|
||||
fn close(&mut self) {}
|
||||
}
|
||||
Reference in New Issue
Block a user