mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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>
239 lines
9.9 KiB
Rust
239 lines
9.9 KiB
Rust
//! 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)
|
||
}
|