diff --git a/Cargo.lock b/Cargo.lock index c463d988..afdd13e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,6 +655,7 @@ dependencies = [ "cadcraft-doc", "cadcraft-dwg", "cadcraft-dxf", + "cadcraft-fonts", "cadcraft-geom", "cadcraft-render", "miniz_oxide 0.8.9", diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 1a5961bf..0217fa96 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -17,6 +17,7 @@ pub mod bidi; mod complex; mod mtext; mod stroke; +pub mod subset; pub mod ttf; use cadcraft_geom::{Bounds2, Vec2}; @@ -131,7 +132,7 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh Some(g) => { let contours = ttf::place_glyph(&g, x, h, wf, shear); if !contours.is_empty() { - out.glyphs.push(contours); + out.push_glyph(contours, ttf::text_glyph(&g, c, x, h, wf, shear)); } adv = g.advance * h / g.cap * wf; } @@ -242,16 +243,53 @@ impl TextFont { /// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines, /// fraction bars) and closed glyph outlines grouped per glyph (fill each group even-odd). +/// Outline glyphs also come as [`TextGlyph`]s, for output that keeps text as text (PDF). #[derive(Clone, Debug, Default, PartialEq)] pub struct Shaped { pub strokes: Vec>, pub glyphs: Vec>>, pub width: f64, + /// The outline glyphs as font glyphs: which face and glyph each of [`Shaped::glyphs`] is, + /// and where it sits. + pub text: Vec, +} + +/// A glyph of an installed outline font, placed: its outline is `Shaped::glyphs[outline]`. +/// Point `(u, v)` of the glyph in em units (1000 font units per em, as PDF glyph space) lands +/// at `origin + x * u / 1000 + y * v / 1000`. +#[derive(Clone, Debug, PartialEq)] +pub struct TextGlyph { + pub face: ttf::Face, + /// Glyph id in the face. + pub gid: u16, + /// The character it shows. + pub ch: char, + /// Index of its outline in [`Shaped::glyphs`]. + pub outline: usize, + /// The glyph origin (on the baseline). + pub origin: Vec2, + /// One em along the baseline (width factor applied). + pub x: Vec2, + /// One em up (obliquing applied). + pub y: Vec2, } impl Shaped { - /// Apply a point transform to every vertex. + /// Add a glyph outline and, when it is a font glyph, its [`TextGlyph`]. + pub(crate) fn push_glyph(&mut self, contours: Vec>, text: Option) { + if let Some(t) = text { + self.text.push(TextGlyph { outline: self.glyphs.len(), ..t }); + } + self.glyphs.push(contours); + } + /// Apply a point transform to every vertex (an affine map: text glyphs keep their axes). pub fn map(&mut self, f: impl Fn(Vec2) -> Vec2) { + for t in &mut self.text { + let o = f(t.origin); + t.x = f(t.origin + t.x) - o; + t.y = f(t.origin + t.y) - o; + t.origin = o; + } for s in &mut self.strokes { for p in s.iter_mut() { *p = f(*p); @@ -267,6 +305,8 @@ impl Shaped { } /// Append another shaped piece (already in the same coordinates). pub fn extend(&mut self, other: Shaped) { + let base = self.glyphs.len(); + self.text.extend(other.text.into_iter().map(|t| TextGlyph { outline: t.outline + base, ..t })); self.strokes.extend(other.strokes); self.glyphs.extend(other.glyphs); } diff --git a/crates/fonts/src/subset.rs b/crates/fonts/src/subset.rs new file mode 100644 index 00000000..b494c3c0 --- /dev/null +++ b/crates/fonts/src/subset.rs @@ -0,0 +1,340 @@ +//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document +//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs +//! become empty), so text written with the original ids shows the same glyphs. +//! +//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted +//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as +//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset +//! here either. + +use std::collections::BTreeSet; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::raw::types::Tag; +use skrifa::{FontRef, MetadataProvider}; + +use crate::ttf::Face; + +/// Largest subset written (bytes). +pub const MAX_SUBSET_BYTES: usize = 32 << 20; +/// Most components followed in one composite glyph, and most glyphs a subset may reach. +const MAX_COMPONENTS: usize = 256; + +/// Whether a face may be embedded as a subset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Embedding { + /// Installable, editable or preview & print embedding (or no OS/2 table). + Allowed, + /// `fsType` restricted licence: no embedding. + Restricted, + /// `fsType` bit 8: the font may only be embedded whole. + NoSubsetting, + /// `fsType` bit 9: only bitmaps may be embedded. + BitmapOnly, + /// No TrueType outlines (`glyf` / `loca`), or the font doesn't parse. + NotTrueType, +} + +/// Why a subset couldn't be made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SubsetError { + /// The font may not be embedded as a subset. + NotAllowed(Embedding), + /// A table is missing or malformed. + Malformed(&'static str), + /// The subset would be larger than [`MAX_SUBSET_BYTES`]. + TooLarge, +} + +fn font(face: &Face) -> Option> { + FontRef::from_index(&face.bytes, face.index).ok() +} + +fn tag(t: &[u8; 4]) -> Tag { + Tag::new(t) +} + +fn table<'a>(f: &FontRef<'a>, t: &[u8; 4]) -> Option<&'a [u8]> { + f.table_data(tag(t)).map(|d| d.as_bytes()) +} + +fn u16_at(b: &[u8], i: usize) -> Option { + Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?])) +} + +fn u32_at(b: &[u8], i: usize) -> Option { + Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?])) +} + +/// The embedding permission of a face. +pub fn embedding(face: &Face) -> Embedding { + let Some(f) = font(face) else { return Embedding::NotTrueType }; + if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() { + return Embedding::NotTrueType; + } + let Some(fs_type) = table(&f, b"OS/2").and_then(|o| u16_at(o, 8)) else { return Embedding::Allowed }; + if fs_type & 0x0200 != 0 { + Embedding::BitmapOnly + } else if fs_type & 0x000F == 0x0002 { + Embedding::Restricted + } else if fs_type & 0x0100 != 0 { + Embedding::NoSubsetting + } else { + Embedding::Allowed + } +} + +/// Font-wide metrics for a font descriptor, in units of 1/1000 em. +#[derive(Clone, Debug, PartialEq)] +pub struct FontInfo { + /// PostScript name: letters, digits and hyphens only (never empty). + pub postscript_name: String, + /// Glyph bounding box: x min, y min, x max, y max. + pub bbox: [f64; 4], + pub ascent: f64, + pub descent: f64, + pub cap_height: f64, + /// Degrees, counter-clockwise from vertical. + pub italic_angle: f64, +} + +/// A finite value or 0. +fn fin(v: f32) -> f64 { + let v = f64::from(v); + if v.is_finite() { v } else { 0.0 } +} + +/// The descriptor metrics of a face. +pub fn info(face: &Face) -> Option { + let f = font(face)?; + let m = f.metrics(Size::new(1000.0), LocationRef::default()); + let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]); + let name: String = f + .localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME) + .english_or_first() + .map(|s| s.to_string()) + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-') + .take(63) + .collect(); + Some(FontInfo { + postscript_name: if name.is_empty() { "Font".into() } else { name }, + bbox, + ascent: fin(m.ascent), + descent: fin(m.descent), + cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72), + italic_angle: fin(m.italic_angle), + }) +} + +/// The advance width of glyph `gid` in units of 1/1000 em. +pub fn advance(face: &Face, gid: u16) -> f64 { + font(face) + .and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid)))) + .map(fin) + .unwrap_or(0.0) +} + +/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length. +fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option> { + let mut v = Vec::with_capacity(n + 1); + for i in 0..=n { + let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 }; + v.push(o.min(glyf_len)); + } + Some(v) +} + +/// The data of glyph `g` (empty when its range is bad). +fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] { + match (offsets.get(g), offsets.get(g + 1)) { + (Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]), + _ => &[], + } +} + +/// The glyph ids a composite glyph is built from. +fn components(data: &[u8]) -> Vec { + let mut out = Vec::new(); + // numberOfContours < 0: a composite. + if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) { + return out; + } + let mut pos = 10; + while out.len() < MAX_COMPONENTS { + let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break }; + out.push(gid); + pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 }; + pos += if flags & 0x0008 != 0 { + 2 + } else if flags & 0x0040 != 0 { + 4 + } else if flags & 0x0080 != 0 { + 8 + } else { + 0 + }; + if flags & 0x0020 == 0 { + break; + } + } + out +} + +/// The sum of a table's big-endian 32-bit words (zero-padded). +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0u32, |sum, c| { + let mut w = [0u8; 4]; + for (d, s) in w.iter_mut().zip(c) { + *d = *s; + } + sum.wrapping_add(u32::from_be_bytes(w)) + }) +} + +/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The +/// other glyphs keep their ids but have no outline. +pub fn subset(face: &Face, gids: &BTreeSet) -> Result, SubsetError> { + let allowed = embedding(face); + if allowed != Embedding::Allowed { + return Err(SubsetError::NotAllowed(allowed)); + } + let f = font(face).ok_or(SubsetError::Malformed("font"))?; + let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?; + let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?; + let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?; + let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?; + let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?); + let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0; + let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?; + // The glyphs to keep: asked for, .notdef, and composite components (transitively). + let mut keep: BTreeSet = BTreeSet::new(); + let mut todo: Vec = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect(); + while let Some(g) = todo.pop() { + if !keep.insert(g) { + continue; + } + for c in components(glyph_data(glyf, &offsets, usize::from(g))) { + if usize::from(c) < n && !keep.contains(&c) { + todo.push(c); + } + } + } + let mut new_glyf: Vec = Vec::new(); + let mut new_loca: Vec = Vec::with_capacity((n + 1) * 4); + for g in 0..n { + new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes()); + if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) { + new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g)); + while !new_glyf.len().is_multiple_of(4) { + new_glyf.push(0); + } + } + if new_glyf.len() > MAX_SUBSET_BYTES { + return Err(SubsetError::TooLarge); + } + } + new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes()); + let mut new_head = head.to_vec(); + // checkSumAdjustment is set once the file is complete; offsets are long now. + new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]); + new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes()); + let mut tables: Vec<([u8; 4], Vec)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())]; + for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] { + if let Some(d) = table(&f, t) { + tables.push((*t, d.to_vec())); + } + } + if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") { + return Err(SubsetError::Malformed("hhea/hmtx")); + } + tables.sort_by_key(|t| t.0); + let out = sfnt(&tables); + if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) } +} + +/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the +/// `head` checksum adjustment. +fn sfnt(tables: &[([u8; 4], Vec)]) -> Vec { + let count = tables.len() as u16; + let mut pow = 1u16; + let mut sel = 0u16; + while pow.saturating_mul(2) <= count { + pow = pow.saturating_mul(2); + sel += 1; + } + let range = pow.saturating_mul(16); + let mut out = Vec::new(); + out.extend_from_slice(&0x0001_0000u32.to_be_bytes()); + for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] { + out.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + let mut head_at = None; + for (t, d) in tables { + out.extend_from_slice(t); + out.extend_from_slice(&checksum(d).to_be_bytes()); + out.extend_from_slice(&(offset as u32).to_be_bytes()); + out.extend_from_slice(&(d.len() as u32).to_be_bytes()); + offset += d.len().div_ceil(4) * 4; + } + for (t, d) in tables { + if t == b"head" { + head_at = Some(out.len()); + } + out.extend_from_slice(d); + while !out.len().is_multiple_of(4) { + out.push(0); + } + } + if let Some(h) = head_at { + let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out)); + if let Some(slot) = out.get_mut(h + 8..h + 12) { + slot.copy_from_slice(&adj.to_be_bytes()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn composite_components_are_followed_and_bounded() { + // numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first). + let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; + g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]); + g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]); + assert_eq!(components(&g), [5, 7]); + // Truncated and endless (always MORE_COMPONENTS) data stay bounded. + assert_eq!(components(&g[..14]), [5]); + let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; + for _ in 0..2000 { + endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]); + } + assert_eq!(components(&endless).len(), MAX_COMPONENTS); + assert!(components(&[0, 1, 0]).is_empty()); + } + + #[test] + fn junk_fonts_are_not_embedded() { + let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 }; + assert_eq!(embedding(&junk), Embedding::NotTrueType); + assert!(subset(&junk, &BTreeSet::from([1])).is_err()); + assert!(info(&junk).is_none()); + assert_eq!(advance(&junk, 3), 0.0); + } + + #[test] + fn sfnt_directory_and_checksums() { + let mut head = vec![0u8; 54]; + head[12..16].copy_from_slice(&0x5F0F_3CF5u32.to_be_bytes()); + let out = sfnt(&[(*b"head", head), (*b"maxp", vec![0, 0, 0x50, 0, 0, 1])]); + assert_eq!(u16_at(&out, 4), Some(2)); + assert_eq!(out.len() % 4, 0); + // The whole file sums to the magic number once the adjustment is in. + assert_eq!(checksum(&out), 0xB1B0_AFBA); + } +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index acda00b8..de322d90 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -35,6 +35,13 @@ pub struct Face { pub index: u32, } +/// The same face of the same loaded file. +impl PartialEq for Face { + fn eq(&self, other: &Face) -> bool { + Arc::ptr_eq(&self.bytes, &other.bytes) && self.index == other.index + } +} + impl std::fmt::Debug for Face { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Face").field("len", &self.bytes.len()).field("index", &self.index).finish() @@ -578,6 +585,9 @@ pub(crate) struct GlyphOutline { pub(crate) cap: f64, /// The font maps the character (false: the missing-glyph shape). mapped: bool, + /// Its glyph id and face, for output that keeps text as text ([`crate::TextGlyph`]). + gid: u32, + face: Option, } /// A glyph by character (through the face's character map) or by glyph id (shaper output). @@ -698,7 +708,8 @@ fn outline(face: &Face, k: GlyphKey) -> Arc { pen.flush(); } let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); - let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap: cap_height(&f), mapped: mapped.is_some() }); + let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32()); + let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap, mapped, gid, face: Some(face.clone()) }); let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner); if cache.len() > MAX_CACHED_GLYPHS { cache.clear(); @@ -722,6 +733,24 @@ fn glyph_or_fallback(face: &Face, c: char) -> Arc { fallback_glyph(c).unwrap_or(g) } +/// The [`crate::TextGlyph`] of a glyph placed like [`place_glyph`] does (its outline index is +/// set when it is added). `None` for the missing-glyph shape. +pub(crate) fn text_glyph(g: &GlyphOutline, c: char, x: f64, h: f64, wf: f64, shear: f64) -> Option { + let gid = u16::try_from(g.gid).ok().filter(|_| g.mapped)?; + let face = g.face.clone()?; + // Font units at 1000 per em scale by h / cap. + let em = 1000.0 * h / g.cap; + (em.is_finite() && em > 0.0).then(|| crate::TextGlyph { + face, + gid, + ch: c, + outline: 0, + origin: Vec2::new(x, 0.0), + x: Vec2::new(em * wf, 0.0), + y: Vec2::new(em * shear, em), + }) +} + /// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`. pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec> { let scale = h / g.cap; @@ -762,7 +791,7 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) if !c.is_whitespace() { let contours = place_glyph(&g, x, h, wf, shear); if !contours.is_empty() { - out.glyphs.push(contours); + out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear)); } } let adv = g.advance * h / g.cap * wf; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 12113caa..55579c82 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -15,6 +15,7 @@ cadcraft-doc = { workspace = true } cadcraft-dxf = { workspace = true } cadcraft-dwg = { workspace = true } cadcraft-render = { workspace = true } +cadcraft-fonts = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 10ad965a..d7efff77 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -21,6 +21,8 @@ use serde_json::Value; use crate::{IoError, Result}; +mod fonts; + /// PDF points per millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; /// Largest sheet side we accept, in millimetres (PDF viewers cap pages at 200 inches anyway). @@ -64,6 +66,10 @@ pub struct PdfOptions { /// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers /// on the sheet becomes a link annotation. pub links: Vec<(Handle, String)>, + /// Write text in installed TrueType fonts as filled outlines instead of as text in an + /// embedded font subset (like AutoCAD's PDFSHX for its own fonts). Stroke-font text is + /// always geometry. + pub text_as_geometry: bool, } impl Default for PdfOptions { @@ -82,6 +88,7 @@ impl Default for PdfOptions { layers: true, include_off_layers: false, links: Vec::new(), + text_as_geometry: false, } } } @@ -96,7 +103,9 @@ impl PdfOptions { _ => None, }; PdfOptions { - // `layers?` (default on), `offLayers?`, `links?: [{handle, url}]`. + // `layers?` (default on), `offLayers?`, `links?: [{handle, url}]`, `text?: "text" | + // "geometry"`. + text_as_geometry: v.get("text").and_then(Value::as_str).is_some_and(|t| t.eq_ignore_ascii_case("geometry")), layers: v.get("layers").and_then(Value::as_bool).unwrap_or(true), include_off_layers: v.get("offLayers").and_then(Value::as_bool).unwrap_or(false), links: links_json(v.get("links")), @@ -262,14 +271,18 @@ pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result>>()?; let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() }; - Ok(assemble(d, &pages, o, &title)) + let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) }; + Ok(assemble(d, &pages, &fonts, o, &title)) } /// One plotted sheet, ready to be written as a page. struct SheetPage { /// Page size in points. media: Vec2, - content: String, + /// What it draws, the mapping to points and the plot area (points). + list: cadcraft_render::DisplayList, + map: Map, + clip_pt: Bounds2, /// The drawing layers (indices in the layer table) of the content's `/L` marks. layers: Vec, /// Link annotations: rectangle in points and target. @@ -326,9 +339,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result { } } } - let content = content_stream(&list, &map, &clip_pt, &layers); let links = links(&list, &map, &clip_pt, &o.links); - Ok(SheetPage { media, content, layers, links, label: sheet_name(space) }) + Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) }) } /// Link annotation rectangles: the area (in points) each linked entity covers on the sheet, @@ -414,7 +426,16 @@ fn color(out: &mut String, c: Rgb, op: &str) { /// The page's marks; those of primitives on `layers` are marked as optional content /// (`/OC /L BDC` … `EMC`). -fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, layers: &[u32]) -> String { +/// +/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its +/// embedded font (`/F`), placed by a text matrix. +fn content_stream( + list: &cadcraft_render::DisplayList, + map: &Map, + clip_pt: &Bounds2, + layers: &[u32], + texts: &std::collections::HashMap, +) -> String { let marked: std::collections::HashSet = layers.iter().copied().collect(); let white = Rgb(255, 255, 255); let mut c = String::new(); @@ -438,8 +459,15 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun }; // The PDF layer (optional content) the marks are in: `/OC /L BDC` … `EMC`. let mut layer: Option = None; + // Inside a text object (`BT` … `ET`), with this font set. + let mut text_font: Option> = None; for (i, p) in list.prims.iter().enumerate() { let want = list.prim_layers.get(i).copied().filter(|l| marked.contains(l)); + let text = u32::try_from(i).ok().and_then(|i| texts.get(&i)); + if text_font.is_some() && (text.is_none() || want != layer) { + c.push_str("ET\n"); + text_font = None; + } if want != layer { flush(&mut c, &mut pending); if layer.is_some() { @@ -502,6 +530,10 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun color(&mut c, rgb, "rg"); fill = Some(rgb); } + if let Some((font, t)) = text { + glyph(&mut c, &mut text_font, *font, t, map); + continue; + } let mut any = false; for t in raw.as_chunks::<3>().0 { for (i, q) in t.iter().enumerate() { @@ -533,6 +565,9 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun } } flush(&mut c, &mut pending); + if text_font.is_some() { + c.push_str("ET\n"); + } if layer.is_some() { c.push_str("EMC\n"); } @@ -540,6 +575,27 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun c } +/// Write one glyph as text: opens a text object and sets the font when needed, then places the +/// glyph with a text matrix (font size 1: the matrix axes are one em). +fn glyph(c: &mut String, text_font: &mut Option>, font: usize, t: &cadcraft_render::PlotText, map: &Map) { + if text_font.is_none() { + c.push_str("BT\n"); + *text_font = Some(None); + } + if *text_font != Some(Some(font)) { + let _ = writeln!(c, "/F{font} 1 Tf"); + *text_font = Some(Some(font)); + } + let s = map.s * map.k; + let g = &t.glyph; + for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] { + num(c, v); + c.push(' '); + } + pt(c, map.pt(g.origin)); + let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid); +} + /// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII, /// else UTF-16BE with a byte order mark (hex). At most 1000 characters. fn pdf_string(s: &str) -> String { @@ -629,7 +685,7 @@ fn refs(ids: impl IntoIterator) -> String { /// Assemble the file: catalog, page tree, pages with their content streams and link /// annotations, the PDF layers (one optional content group per drawing layer used, named /// like it), page labels, document information, cross-reference table and trailer. -fn assemble(d: &Drawing, pages: &[SheetPage], o: &PdfOptions, title: &str) -> Vec { +fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec { let mut objs = Objects::default(); let catalog = objs.reserve(); let tree = objs.reserve(); @@ -637,9 +693,15 @@ fn assemble(d: &Drawing, pages: &[SheetPage], o: &PdfOptions, title: &str) -> Ve let mut ocg_ids: std::collections::HashMap = std::collections::HashMap::new(); let mut ocgs: Vec<(u32, usize)> = Vec::new(); let mut kids = Vec::new(); + // Page dictionaries are completed once the fonts are written: (page, its fonts, the rest). + let mut page_dicts: Vec<(usize, Vec, String, String)> = Vec::new(); for p in pages { let page = objs.reserve(); - let content = objs.add(stream(p.content.as_bytes(), o.compress, "")); + let texts = fonts.texts(&p.list); + let content = objs.add(stream(content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts).as_bytes(), o.compress, "")); + let mut used: Vec = texts.values().map(|(f, _)| *f).collect(); + used.sort_unstable(); + used.dedup(); let mut props = String::new(); for l in &p.layers { let Some(layer) = d.layers.get(*l as usize) else { continue }; @@ -665,11 +727,22 @@ fn assemble(d: &Drawing, pages: &[SheetPage], o: &PdfOptions, title: &str) -> Ve } let mut mb = String::new(); pt(&mut mb, p.media); - let resources = if props.is_empty() { "<< >>".to_string() } else { format!("<< /Properties <<{props} >> >>") }; let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) }; - objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /MediaBox [0 0 {mb}] /Resources {resources} /Contents {content} 0 R{annots} >>")); + page_dicts.push((page, used, props, format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}"))); kids.push(page); } + let font_ids = if fonts.is_empty() { Vec::new() } else { fonts.write(&mut objs, o.compress) }; + for (page, used, props, rest) in page_dicts { + let mut res = String::new(); + if !props.is_empty() { + let _ = write!(res, " /Properties <<{props} >>"); + } + let f: Vec = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect(); + if !f.is_empty() { + let _ = write!(res, " /Font << {} >>", f.join(" ")); + } + objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /Resources <<{res} >> {rest} >>")); + } objs.set(tree, format!("<< /Type /Pages /Kids [{}] /Count {} >>", refs(kids.iter().copied()), kids.len())); let mut cat = format!("<< /Type /Catalog /Pages {tree} 0 R"); if !ocgs.is_empty() { diff --git a/crates/io/src/pdf/fonts.rs b/crates/io/src/pdf/fonts.rs new file mode 100644 index 00000000..56b3d084 --- /dev/null +++ b/crates/io/src/pdf/fonts.rs @@ -0,0 +1,180 @@ +//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are +//! written as text in embedded TrueType subsets, so the text can be selected, searched and +//! copied. Each font is a `Type0` font with `Identity-H` encoding over a `CIDFontType2` +//! descendant whose CIDs are the glyph ids (`/CIDToGIDMap /Identity`), with a `ToUnicode` +//! map for copying and searching. +//! +//! Fonts whose licence bits forbid embedding a subset, fonts without TrueType outlines, and +//! anything past the caps on fonts and font data stay as filled outlines. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt::Write as _; + +use cadcraft_fonts::subset::{self, FontInfo}; +use cadcraft_fonts::ttf::Face; +use cadcraft_render::{DisplayList, PlotText}; + +use super::{Objects, num, refs, stream}; + +/// Most embedded fonts in one file. +const MAX_FONTS: usize = 64; +/// Most embedded font data in one file (uncompressed bytes). +const MAX_FONT_BYTES: usize = 64 << 20; + +/// A face's identity: its loaded file and face index. +type FaceKey = (usize, usize, u32); + +fn key(f: &Face) -> FaceKey { + (f.bytes.as_ptr() as usize, f.bytes.len(), f.index) +} + +/// An embedded font. +struct Font { + face: Face, + /// Glyphs used, and the character each shows. + chars: BTreeMap, + /// The subset file. + data: Vec, + info: FontInfo, +} + +/// The fonts of a document: those that can be embedded, by face. +#[derive(Default)] +pub(super) struct Fonts { + fonts: Vec, + by_face: HashMap>, +} + +impl Fonts { + /// Collect the glyphs of every plotted text glyph and subset their fonts. + pub(super) fn collect<'a>(lists: impl IntoIterator) -> Fonts { + let mut used: Vec<(Face, BTreeMap)> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for list in lists { + for t in usable(list) { + let k = key(&t.glyph.face); + let i = match index.get(&k) { + Some(i) => *i, + None => { + index.insert(k, used.len()); + used.push((t.glyph.face.clone(), BTreeMap::new())); + used.len() - 1 + } + }; + if let Some((_, chars)) = used.get_mut(i) { + chars.entry(t.glyph.gid).or_insert(t.glyph.ch); + } + } + } + let mut out = Fonts::default(); + let mut total = 0usize; + for (face, chars) in used { + let k = key(&face); + let font = (out.fonts.len() < MAX_FONTS) + .then(|| subset::subset(&face, &chars.keys().copied().collect::>()).ok()) + .flatten() + .filter(|data| total.saturating_add(data.len()) <= MAX_FONT_BYTES) + .and_then(|data| Some(Font { info: subset::info(&face)?, face, chars, data })); + match font { + Some(f) => { + total = total.saturating_add(f.data.len()); + out.by_face.insert(k, Some(out.fonts.len())); + out.fonts.push(f); + } + None => { + out.by_face.insert(k, None); + } + } + } + out + } + + pub(super) fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + /// The embedded font (index) a glyph is written in; `None`: draw its outline. + fn font_of(&self, t: &PlotText) -> Option { + self.by_face.get(&key(&t.glyph.face)).copied().flatten() + } + + /// The plotted glyphs of `list` written as text: outline primitive → (font, glyph). + pub(super) fn texts<'a>(&self, list: &'a DisplayList) -> HashMap { + usable(list).filter_map(|t| Some((t.prim, (self.font_of(t)?, t)))).collect() + } + + /// Write the font objects; returns each font's `Type0` object, by font index. + pub(super) fn write(&self, objs: &mut Objects, compress: bool) -> Vec { + self.fonts.iter().map(|f| write_font(objs, f, compress)).collect() + } +} + +/// The text glyphs of a list that stand for a primitive of it. +fn usable(list: &DisplayList) -> impl Iterator { + list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0) +} + +/// Six capital letters naming a subset (PDF subset fonts are `ABCDEF+Name`), from its glyphs. +fn subset_tag(f: &Font) -> String { + let mut h: u64 = 0xCBF2_9CE4_8422_2325; + for b in f.info.postscript_name.bytes().chain(f.chars.keys().flat_map(|g| g.to_be_bytes())) { + h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01B3); + } + (0..6).map(|i| char::from(b'A' + ((h >> (i * 8)) % 26) as u8)).collect() +} + +fn write_font(objs: &mut Objects, f: &Font, compress: bool) -> usize { + let name = format!("{}+{}", subset_tag(f), f.info.postscript_name); + let file = objs.add(stream(&f.data, compress, &format!(" /Length1 {}", f.data.len()))); + let mut bbox = String::new(); + for (i, v) in f.info.bbox.iter().enumerate() { + if i > 0 { + bbox.push(' '); + } + num(&mut bbox, v.round()); + } + let mut metrics = String::new(); + for (k, v) in [("Ascent", f.info.ascent), ("Descent", f.info.descent), ("CapHeight", f.info.cap_height), ("ItalicAngle", f.info.italic_angle)] { + let _ = write!(metrics, " /{k} "); + num(&mut metrics, v.round()); + } + let descriptor = + objs.add(format!("<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [{bbox}]{metrics} /StemV 80 /FontFile2 {file} 0 R >>")); + let mut widths = String::new(); + for g in f.chars.keys() { + let _ = write!(widths, "{g} ["); + num(&mut widths, subset::advance(&f.face, *g).round()); + widths.push_str("] "); + } + let cid = objs.add(format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + widths.trim_end() + )); + let to_unicode = objs.add(stream(to_unicode(&f.chars).as_bytes(), compress, "")); + objs.add(format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{}] /ToUnicode {to_unicode} 0 R >>", + refs([cid]) + )) +} + +/// A `ToUnicode` CMap from glyph ids (2-byte codes) to the characters they show. +fn to_unicode(chars: &BTreeMap) -> String { + let mut s = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let entries: Vec<(&u16, &char)> = chars.iter().collect(); + // At most 100 entries per block. + for block in entries.chunks(100) { + let _ = writeln!(s, "{} beginbfchar", block.len()); + for (g, c) in block { + let _ = write!(s, "<{g:04X}> <"); + for u in c.encode_utf16(&mut [0; 2]) { + let _ = write!(s, "{u:04X}"); + } + s.push_str(">\n"); + } + s.push_str("endbfchar\n"); + } + s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + s +} diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs new file mode 100644 index 00000000..4863ee62 --- /dev/null +++ b/crates/io/tests/pdf_text.rs @@ -0,0 +1,372 @@ +//! Real text in PDF plots (issue #560): text in TrueType fonts is written as text in an +//! embedded font subset (selectable and searchable through a ToUnicode map), placed with its +//! rotation, width factor and obliquing; fonts whose licence bits forbid embedding, the stroke +//! font and the `text: "geometry"` option keep filled outlines. +//! +//! The fonts are tiny synthetic TrueType files built here (no font files in the repository). + +use cadcraft_doc::{Common, Drawing, EntityKind, Space, Text, TextStyle, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_io::plot; +use serde_json::json; + +// ---------- a minimal TrueType font: .notdef, "A" (triangle), "B" (square) ---------- + +fn be16(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_be_bytes()); +} +fn be32(v: &mut Vec, x: u32) { + v.extend_from_slice(&x.to_be_bytes()); +} + +/// A simple glyph: one closed contour of on-curve points. +fn simple_glyph(pts: &[(i16, i16)]) -> Vec { + let mut g = Vec::new(); + let xs: Vec = pts.iter().map(|p| p.0).collect(); + let ys: Vec = pts.iter().map(|p| p.1).collect(); + be16(&mut g, 1); + for v in [*xs.iter().min().unwrap(), *ys.iter().min().unwrap(), *xs.iter().max().unwrap(), *ys.iter().max().unwrap()] { + be16(&mut g, v as u16); + } + be16(&mut g, pts.len() as u16 - 1); + be16(&mut g, 0); + g.extend(std::iter::repeat_n(1u8, pts.len())); + let (mut px, mut py) = (0i16, 0i16); + for (x, _) in pts { + be16(&mut g, (x - px) as u16); + px = *x; + } + for (_, y) in pts { + be16(&mut g, (y - py) as u16); + py = *y; + } + while g.len() % 4 != 0 { + g.push(0); + } + g +} + +/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`. +fn test_font(fs_type: u16) -> Vec { + let glyphs = [Vec::new(), simple_glyph(&[(50, 0), (550, 0), (300, 700)]), simple_glyph(&[(50, 0), (550, 0), (550, 700), (50, 700)])]; + let mut glyf = Vec::new(); + let mut loca = Vec::new(); + for g in &glyphs { + be32(&mut loca, glyf.len() as u32); + glyf.extend_from_slice(g); + } + be32(&mut loca, glyf.len() as u32); + let mut head = Vec::new(); + be32(&mut head, 0x0001_0000); + be32(&mut head, 0x0001_0000); + be32(&mut head, 0); + be32(&mut head, 0x5F0F_3CF5); + be16(&mut head, 0); + be16(&mut head, 1000); + head.extend_from_slice(&[0; 16]); + for v in [0i16, 0, 600, 700] { + be16(&mut head, v as u16); + } + for v in [0u16, 8, 2, 1, 0] { + be16(&mut head, v); + } + let mut hhea = Vec::new(); + be32(&mut hhea, 0x0001_0000); + for v in [800i16, -200, 0, 600, 0, 0, 600, 1, 0, 0, 0, 0, 0, 0, 0] { + be16(&mut hhea, v as u16); + } + be16(&mut hhea, 3); + let mut maxp = Vec::new(); + be32(&mut maxp, 0x0001_0000); + for v in [3u16, 4, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] { + be16(&mut maxp, v); + } + let mut hmtx = Vec::new(); + for _ in 0..3 { + be16(&mut hmtx, 600); + be16(&mut hmtx, 50); + } + // cmap: format 4, 'A'..'B' → 1..2. + let mut sub = Vec::new(); + let segs: [(u16, u16, u16); 2] = [(0x41, 0x42, 1u16.wrapping_sub(0x41)), (0xFFFF, 0xFFFF, 1)]; + be16(&mut sub, 4); + be16(&mut sub, (16 + segs.len() * 8) as u16); + be16(&mut sub, 0); + be16(&mut sub, (segs.len() * 2) as u16); + for v in [4u16, 1, 0] { + be16(&mut sub, v); + } + for s in segs { + be16(&mut sub, s.1); + } + be16(&mut sub, 0); + for s in segs { + be16(&mut sub, s.0); + } + for s in segs { + be16(&mut sub, s.2); + } + for _ in segs { + be16(&mut sub, 0); + } + let mut cmap = Vec::new(); + for v in [0u16, 1, 3, 1] { + be16(&mut cmap, v); + } + be32(&mut cmap, 12); + cmap.extend_from_slice(&sub); + let mut os2 = vec![0u8; 78]; + os2[8..10].copy_from_slice(&fs_type.to_be_bytes()); + let mut tables: Vec<(&[u8; 4], Vec)> = + vec![(b"OS/2", os2), (b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp)]; + tables.sort_by_key(|t| *t.0); + let mut out = Vec::new(); + be32(&mut out, 0x0001_0000); + for v in [tables.len() as u16, 128, 3, tables.len() as u16 * 16 - 128] { + be16(&mut out, v); + } + let mut off = 12 + 16 * tables.len(); + for (t, d) in &tables { + out.extend_from_slice(*t); + be32(&mut out, 0); + be32(&mut out, off as u32); + be32(&mut out, d.len() as u32); + off += d.len().div_ceil(4) * 4; + } + for (_, d) in &tables { + out.extend_from_slice(d); + while out.len() % 4 != 0 { + out.push(0); + } + } + out +} + +/// Register the test fonts once: an embeddable one and one with a restricted licence. +fn fonts() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0)); + cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002)); + }); +} + +// ---------- drawings and PDF reading ---------- + +fn style(d: &mut Drawing, name: &str, font: &str) { + d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); +} + +fn text(d: &mut Drawing, space: &Space, style: &str, at: (f64, f64), value: &str, rotation: f64, width_factor: f64, oblique: f64) { + let t = Text { + insert: Vec3::new(at.0, at.1, 0.0), + align_pt: None, + height: 10.0, + value: value.into(), + rotation, + width_factor, + oblique, + style: style.into(), + halign: Default::default(), + valign: Default::default(), + }; + d.add(space, Common::default(), EntityKind::Text(t)).unwrap(); +} + +fn drawing() -> Drawing { + fonts(); + let mut d = Drawing::new_metric(); + style(&mut d, "Real", "cadcraft-pdf-test.ttf"); + style(&mut d, "Locked", "cadcraft-pdf-restricted.ttf"); + d +} + +/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`). +fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String { + let mut o = json!({"compress": false}); + if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) { + m.extend(e.clone()); + } + let bytes = plot(d, space, &o).unwrap(); + check_xref(&bytes); + bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect() +} + +/// The cross-reference table points at every object. +fn check_xref(bytes: &[u8]) { + let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect(); + let sx = text.rfind("startxref\n").unwrap(); + let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap(); + let mut lines = text[xref..].lines().skip(1); + let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); + for (i, e) in lines.take(count).enumerate().skip(1) { + let off: usize = e[..10].parse().unwrap(); + assert!(text[off..].starts_with(&format!("{i} 0 obj\n")), "object {i}"); + } +} + +/// The text matrices (`a b c d e f Tm`) of the content, in order. +fn matrices(pdf: &str) -> Vec<[f64; 6]> { + pdf.lines() + .filter(|l| l.ends_with(" Tj") && l.contains(" Tm ")) + .map(|l| { + let v: Vec = l.split(" Tm ").next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect(); + [v[0], v[1], v[2], v[3], v[4], v[5]] + }) + .collect() +} + +/// The embedded font file (FontFile2 stream data). +fn font_file(pdf: &str, bytes_of: &[u8]) -> Vec { + let i = pdf.find("/Length1 ").expect("a FontFile2 stream"); + let head = &pdf[..i]; + let len_at = head.rfind("/Length ").unwrap() + 8; + let len: usize = pdf[len_at..].split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap(); + let start = pdf[i..].find("stream\n").unwrap() + i + 7; + bytes_of[start..start + len].to_vec() +} + +#[test] +fn truetype_text_is_embedded_as_text() { + let mut d = drawing(); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0); + let p = pdf(&d, &Space::Model, json!({})); + // A Type0 font over a CIDFontType2 subset with a ToUnicode map. + assert!(p.contains("/Subtype /Type0") && p.contains("/Encoding /Identity-H") && p.contains("/Subtype /CIDFontType2"), "{p}"); + assert!(p.contains("/CIDToGIDMap /Identity") && p.contains("/FontFile2 ") && p.contains("/ToUnicode ")); + let base = p.split("/BaseFont /").nth(1).unwrap().split_whitespace().next().unwrap(); + assert!(base.len() == 11 && base.as_bytes()[6] == b'+' && base[..6].chars().all(|c| c.is_ascii_uppercase()), "{base}"); + assert!(p.contains("<0001> <0041>") && p.contains("<0002> <0042>"), "ToUnicode maps glyphs to A and B"); + assert!(p.contains("/W [1 [600] 2 [600]]"), "widths"); + // The page uses the font; four glyphs written as text, in a text object. + assert!(p.contains("/Font << /F0 ")); + let tj: Vec<&str> = p.lines().filter(|l| l.ends_with(" Tj")).map(|l| l.rsplit(' ').nth(1).unwrap()).collect(); + assert_eq!(tj, ["<0001>", "<0002>", "<0002>", "<0001>"]); + assert!(p.contains("BT\n/F0 1 Tf\n") && p.contains("ET\n")); + assert_eq!(p.matches("BT\n").count(), p.matches("ET\n").count()); + // No filled outlines left for them. + assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}"); + // The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be. + let m = matrices(&p); + assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0)); + let step = m[1][4] - m[0][4]; + assert!((step - 0.6 * m[0][0]).abs() < 0.01, "advance {step} for em {}", m[0][0]); + assert!(m.windows(2).all(|w| (w[0][5] - w[1][5]).abs() < 1e-3), "one baseline"); +} + +#[test] +fn the_subset_is_a_valid_truetype_font() { + let mut d = drawing(); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0); + let bytes = plot(&d, &Space::Model, &json!({"compress": false})).unwrap(); + let p: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect(); + let file = font_file(&p, &bytes); + assert!(p.contains(&format!("/Length1 {}", file.len()))); + let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses"); + assert_eq!(cadcraft_fonts::subset::embedding(&face), cadcraft_fonts::subset::Embedding::Allowed); + // Glyph A kept, B (unused) emptied: the subset is smaller than the font. + assert!(file.len() < test_font(0).len()); + // Whole-file checksum adjusted. + let sum = file.chunks(4).fold(0u32, |s, c| { + let mut w = [0u8; 4]; + w[..c.len()].copy_from_slice(c); + s.wrapping_add(u32::from_be_bytes(w)) + }); + assert_eq!(sum, 0xB1B0_AFBA); +} + +#[test] +fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { + let mut d = drawing(); + let quarter = std::f64::consts::FRAC_PI_2; + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", quarter, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians()); + let m = matrices(&pdf(&d, &Space::Model, json!({}))); + assert_eq!(m.len(), 3); + // Rotated a quarter turn: the baseline points up, "up" points left. + let [a, b, c, dd, _, _] = m[0]; + assert!(a.abs() < 1e-3 && b > 0.0 && c < 0.0 && dd.abs() < 1e-3, "{:?}", m[0]); + // Width factor 2: twice as wide as high. + assert!((m[1][0] - 2.0 * m[1][3]).abs() < 1e-2 && m[1][1].abs() < 1e-3, "{:?}", m[1]); + // Oblique 15°: the up axis leans right by tan 15°. + assert!((m[2][2] - m[2][3] * 15f64.to_radians().tan()).abs() < 1e-2, "{:?}", m[2]); + // The em is sized so the cap height (here 700/1000 em) is the text height: 10 / 0.7. + let em_pt = m[1][3]; + let em_mm = em_pt * 25.4 / 72.0; + assert!(em_mm > 0.0); +} + +#[test] +fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() { + let mut d = drawing(); + text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + let p = pdf(&d, &Space::Model, json!({})); + assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines"); + let mut d = drawing(); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + let p = pdf(&d, &Space::Model, json!({"text": "geometry"})); + assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry"); + // The stroke font is always geometry. + let mut d = drawing(); + text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + let p = pdf(&d, &Space::Model, json!({})); + assert!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n")); +} + +#[test] +fn text_seen_through_a_viewport_and_on_layers() { + let mut d = drawing(); + let paper = Space::Paper("Layout1".into()); + // Model: one word well inside the viewport, one cut by its right edge. + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (88.0, 0.0), "BA", 0.0, 1.0, 0.0); + let vp = Viewport { + center: Vec3::new(150.0, 100.0, 0.0), + width: 100.0, + height: 100.0, + view_center: Vec2::new(50.0, 5.0), + view_height: 100.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip: None, + on: true, + }; + d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); + text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0); + let p = pdf(&d, &paper, json!({})); + let tj = p.lines().filter(|l| l.ends_with(" Tj")).count(); + // "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The + // glyph the edge cuts stays a (clipped) outline. + assert_eq!(tj, 4, "{p}"); + assert!(p.contains("h\nf\n"), "the cut glyph is an outline"); + // Text sits in its layer's marked content. + let first_bt = p.find("BT\n").unwrap(); + assert!(p[..first_bt].contains("/OC /L0 BDC")); +} + +#[test] +fn hostile_text_never_panics() { + let mut d = drawing(); + for (v, h, r) in + [("A".repeat(5000), 10.0, 0.0), ("AB".into(), 1e300, 0.0), ("AB".into(), 1e-300, 1e300), ("\u{0}A\u{FFFF}B".into(), 5.0, f64::NAN)] + { + let t = Text { + insert: Vec3::new(0.0, 0.0, 0.0), + align_pt: None, + height: h, + value: v, + rotation: r, + width_factor: 1.0, + oblique: 0.0, + style: "Real".into(), + halign: Default::default(), + valign: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Text(t)).unwrap(); + } + pdf(&d, &Space::Model, json!({})); + pdf(&d, &Space::Model, json!({"compress": true})); +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 9bfa37e2..1c976733 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -36,6 +36,8 @@ pub struct DimGeometry { pub text: Vec>, /// Text glyph outlines (TrueType), grouped per glyph. pub text_glyphs: Vec>>, + /// The font glyphs of `text_glyphs` (outline indices into it). + pub text_runs: Vec, pub text_pos: Vec2, pub text_angle: f64, pub text_height: f64, @@ -948,6 +950,8 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font if !g.mtext.is_empty() { let l = cadcraft_fonts::layout_mtext_with(&g.mtext, ¶ms(text_mid, text_angle)); for p in l.pieces { + let base = g.text_glyphs.len(); + g.text_runs.extend(p.shaped.text.into_iter().map(|t| cadcraft_fonts::TextGlyph { outline: t.outline + base, ..t })); g.text.extend(p.shaped.strokes); g.text_glyphs.extend(p.shaped.glyphs); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 74fc553d..a380d611 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -80,6 +80,9 @@ pub struct DisplayList { /// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the /// layer isn't in the table), for PDF layers. Empty for other lists. pub prim_layers: Vec, + /// Plots: outline-font glyphs that a plotter can write as text instead of drawing the + /// filled outline primitive they stand for ([`PlotText::prim`]), in drawing order. + pub texts: Vec, pub bounds: Bounds2, /// The paper sheet when the list was built for a layout (paper space); the UI draws the /// white sheet, its shadow and the printable-area outline from it. @@ -91,6 +94,28 @@ pub struct DisplayList { pub pens: Vec, } +/// A glyph of plotted text: the font glyph and, in world coordinates, where it sits. It is +/// drawn by primitive [`PlotText::prim`] (a filled outline in the primitive's colour); output +/// with real text writes the glyph instead of that primitive. +#[derive(Clone, Debug, PartialEq)] +pub struct PlotText { + /// The filled outline primitive (index in [`DisplayList::prims`]); [`PlotText::NONE`] when + /// the glyph has none in the list (clipped by a viewport). + pub prim: u32, + /// Triangle vertices of the outline when it was drawn, and their bounding box (a clipped + /// outline has others). + pub verts: u32, + pub bounds: Bounds2, + /// The glyph; its origin and em axes are in world coordinates. + pub glyph: cadcraft_fonts::TextGlyph, +} + +impl PlotText { + pub const NONE: u32 = u32::MAX; + /// [`PlotText::prim`] of a glyph cut by a viewport edge. + pub const CLIPPED: u32 = u32::MAX - 1; +} + impl DPrim { /// The colour to draw with on `background`. pub fn display_rgb(&self, background: Rgb) -> Rgb { @@ -272,10 +297,23 @@ impl Builder<'_> { return; } let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0; - for g in &sh.glyphs { + // Plots note which filled outlines are font glyphs (see `DisplayList::texts`). + let mut text = if self.plotting && fill { sh.text.iter().peekable() } else { [].iter().peekable() }; + for (i, g) in sh.glyphs.iter().enumerate() { if fill { let tris = fill::triangulate_evenodd(g); + let at = self.list.prims.len(); self.tris(ctx, color, &tris); + while text.next_if(|t| t.outline < i).is_some() {} + if let Some(t) = text.next_if(|t| t.outline == i) + && self.list.prims.len() > at + && let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(tris.len())) + { + let xf = &ctx.xf; + let glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() }; + let bounds = self.list.prims.get(at).map(|p| Bounds2::from_points(self.list.points(p).iter().copied())).unwrap_or(Bounds2::EMPTY); + self.list.texts.push(PlotText { prim, verts, bounds, glyph }); + } } else { for c in g { self.polyline(ctx, color, lw, c); @@ -441,6 +479,17 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor continue; } entity(&mut sub, &Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }, me); + // Plotted text glyphs move to the layout's list; their outlines carry the glyph's + // index there in their handle's high half until clipped (see `take_viewport_layers`). + for t in std::mem::take(&mut sub.list.texts) { + let k = b.list.texts.len() as u64 + 1; + if let Some(p) = sub.list.prims.get_mut(t.prim as usize) + && k < u64::from(u32::MAX) + { + p.handle = Handle(p.handle.0 | (k << 32)); + b.list.texts.push(PlotText { prim: PlotText::NONE, ..t }); + } + } } // The contents are in paper units now: draw their pens before clipping. pen::apply(&mut sub.list, paper::paper_unit_mm(d)); @@ -570,11 +619,29 @@ fn entity_layer(ctx: &Ctx, e: &Entity) -> u32 { /// After a viewport is appended to a plot: the layers its contents carry in their handles /// (see [`entity`]) go to [`DisplayList::prim_layers`], and the handles back to /// [`VIEWPORT_CONTENT`]. The viewport's border was tagged when it was drawn. +/// +/// Text glyphs seen through the viewport (marked in the handles' high half) keep their outline +/// primitive when it came through the clip whole; a glyph cut by the viewport edge stays +/// geometry. fn take_viewport_layers(list: &mut DisplayList) { let start = list.prim_layers.len(); - for p in list.prims.iter_mut().skip(start) { - list.prim_layers.push(u32::try_from(p.handle.0).unwrap_or(NO_LAYER)); - p.handle = VIEWPORT_CONTENT; + for i in start..list.prims.len() { + let Some(p) = list.prims.get(i).copied() else { break }; + list.prim_layers.push(u32::try_from(p.handle.0 & 0xFFFF_FFFF).unwrap_or(NO_LAYER)); + let bounds = Bounds2::from_points(list.points(&p).iter().copied()); + if let Some(t) = (p.handle.0 >> 32).checked_sub(1).and_then(|k| list.texts.get_mut(k as usize)) { + let same = |a: Vec2, b: Vec2| a.dist(b) <= 1e-9 * (1.0 + a.len()); + let whole = t.prim == PlotText::NONE + && p.kind == Kind::Tris + && p.len == t.verts + && same(bounds.min, t.bounds.min) + && same(bounds.max, t.bounds.max); + // A second piece (or a cut one) means the clip changed the glyph. + t.prim = if whole { u32::try_from(i).unwrap_or(PlotText::NONE) } else { PlotText::CLIPPED }; + } + if let Some(p) = list.prims.get_mut(i) { + p.handle = VIEWPORT_CONTENT; + } } } @@ -1044,7 +1111,7 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio b.tris(ctx, dim_rgb, t); } if b.opts.text { - let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0 }; + let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0, text: g.text_runs.clone() }; b.shaped(ctx, txt_rgb, lw, &sh); } }