PDF export: real text in embedded TrueType subsets (part of #560) (#571)

Squash of PR #571 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:24:14 -04:00
1 parent 70b0cce997
commit 7a13ca7d05
10 files changed
+1126 -19

No files matched your search

Generated
+1
View File
@@ -655,6 +655,7 @@ dependencies = [
"cadcraft-doc",
"cadcraft-dwg",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
+42 -2
View File
@@ -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<Vec<Vec2>>,
pub glyphs: Vec<Vec<Vec<Vec2>>>,
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<TextGlyph>,
}
/// 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<Vec<Vec2>>, text: Option<TextGlyph>) {
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);
}
+340
View File
@@ -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<'_>> {
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<u16> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
}
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
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<FontInfo> {
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<Vec<usize>> {
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<u16> {
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<u16>) -> Result<Vec<u8>, 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<u16> = BTreeSet::new();
let mut todo: Vec<u16> = 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<u8> = Vec::new();
let mut new_loca: Vec<u8> = 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<u8>)> = 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<u8>)]) -> Vec<u8> {
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);
}
}
+31 -2
View File
@@ -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<Face>,
}
/// 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<GlyphOutline> {
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<GlyphOutline> {
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<crate::TextGlyph> {
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<Vec<Vec2>> {
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;
+1
View File
@@ -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 }
+83 -10
View File
@@ -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<Vec<u
};
let pages = spaces.iter().map(|s| sheet(drawn, s, o)).collect::<Result<Vec<_>>>()?;
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<i>` marks.
layers: Vec<u32>,
/// Link annotations: rectangle in points and target.
@@ -326,9 +339,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
}
}
}
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<layer> 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<font>`), placed by a text matrix.
fn content_stream(
list: &cadcraft_render::DisplayList,
map: &Map,
clip_pt: &Bounds2,
layers: &[u32],
texts: &std::collections::HashMap<u32, (usize, &cadcraft_render::PlotText)>,
) -> String {
let marked: std::collections::HashSet<u32> = 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<i> BDC` … `EMC`.
let mut layer: Option<u32> = None;
// Inside a text object (`BT` … `ET`), with this font set.
let mut text_font: Option<Option<usize>> = 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<Option<usize>>, 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<Item = usize>) -> 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<u8> {
fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec<u8> {
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<u32, usize> = 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<usize>, 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<usize> = 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<String> = 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() {
+180
View File
@@ -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<u16, char>,
/// The subset file.
data: Vec<u8>,
info: FontInfo,
}
/// The fonts of a document: those that can be embedded, by face.
#[derive(Default)]
pub(super) struct Fonts {
fonts: Vec<Font>,
by_face: HashMap<FaceKey, Option<usize>>,
}
impl Fonts {
/// Collect the glyphs of every plotted text glyph and subset their fonts.
pub(super) fn collect<'a>(lists: impl IntoIterator<Item = &'a DisplayList>) -> Fonts {
let mut used: Vec<(Face, BTreeMap<u16, char>)> = Vec::new();
let mut index: HashMap<FaceKey, usize> = 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::<BTreeSet<u16>>()).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<usize> {
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<u32, (usize, &'a PlotText)> {
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<usize> {
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<Item = &PlotText> {
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<u16, char>) -> 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> <FFFF>\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
}
+372
View File
@@ -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<u8>, x: u16) {
v.extend_from_slice(&x.to_be_bytes());
}
fn be32(v: &mut Vec<u8>, 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<u8> {
let mut g = Vec::new();
let xs: Vec<i16> = pts.iter().map(|p| p.0).collect();
let ys: Vec<i16> = 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<u8> {
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<u8>)> =
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<f64> = 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<u8> {
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}));
}
+4
View File
@@ -36,6 +36,8 @@ pub struct DimGeometry {
pub text: Vec<Vec<Vec2>>,
/// Text glyph outlines (TrueType), grouped per glyph.
pub text_glyphs: Vec<Vec<Vec<Vec2>>>,
/// The font glyphs of `text_glyphs` (outline indices into it).
pub text_runs: Vec<cadcraft_fonts::TextGlyph>,
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, &params(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);
}
+72 -5
View File
@@ -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<u32>,
/// 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<PlotText>,
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<Pen>,
}
/// 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);
}
}