diff --git a/Cargo.lock b/Cargo.lock index e5076e82e..48e90a2b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-udev" version = "0.1.2" @@ -674,6 +680,7 @@ dependencies = [ "cadcraft-fonts", "cadcraft-geom", "cadcraft-render", + "craft-pdf", "miniz_oxide 0.9.1", "quick-xml 0.42.0", "serde", @@ -868,6 +875,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colored" version = "2.2.0" @@ -948,6 +961,16 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +dependencies = [ + "hayro-syntax", + "krilla", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1311,7 +1334,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1427,6 +1450,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "float_next_after" version = "1.0.0" @@ -1439,6 +1471,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.7" @@ -1582,6 +1623,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1775,6 +1826,39 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +dependencies = [ + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +dependencies = [ + "hayro-jbig2", + "hayro-jpeg2000", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg", +] + [[package]] name = "heapless" version = "0.8.0" @@ -1932,6 +2016,22 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.2" @@ -2092,6 +2192,32 @@ dependencies = [ "accesskit_consumer 0.35.0", ] +[[package]] +name = "krilla" +version = "0.8.2" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +dependencies = [ + "base64", + "bumpalo", + "flate2", + "float-cmp", + "gif", + "image-webp", + "imagesize", + "indexmap", + "pdf-writer", + "png", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.42.1", + "smallvec", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke", + "zune-jpeg", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2827,6 +2953,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags 2.13.2", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3153,6 +3291,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -3160,7 +3308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.7", "once_cell", ] @@ -3172,7 +3320,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", "foldhash", - "font-types", + "font-types 0.12.7", "once_cell", "yoke", ] @@ -3461,6 +3609,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -3604,6 +3762,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo", + "rustc-hash 2.1.3", + "skrifa 0.42.1", + "write-fonts", +] + [[package]] name = "syn" version = "2.0.119" @@ -3758,6 +3939,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" @@ -3894,6 +4081,21 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4689,6 +4891,19 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap", + "kurbo", + "log", + "read-fonts 0.39.2", +] + [[package]] name = "writeable" version = "0.6.4" @@ -4758,6 +4973,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xmp-writer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" + [[package]] name = "xtask" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 6e1afaa06..d3ea9a09a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin +# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } # foundation thiserror = "2" @@ -58,6 +61,11 @@ egui-wgpu = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } +[patch.crates-io] +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } + [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/fonts/src/subset.rs b/crates/fonts/src/subset.rs index b494c3c01..18f59872b 100644 --- a/crates/fonts/src/subset.rs +++ b/crates/fonts/src/subset.rs @@ -1,25 +1,13 @@ -//! 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. +//! Whether a font may be embedded as a subset in exported files (PDF), from its 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 embedded either. -use std::collections::BTreeSet; - -use skrifa::instance::{LocationRef, Size}; +use skrifa::FontRef; 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 { @@ -35,40 +23,17 @@ pub enum Embedding { 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()) + f.table_data(Tag::new(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 }; + let Ok(f) = FontRef::from_index(&face.bytes, face.index) 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; } @@ -84,257 +49,14 @@ pub fn embedding(face: &Face) -> Embedding { } } -/// 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); + assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType); } } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 395ec4966..2e8b3cc80 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -20,9 +20,12 @@ thiserror = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } miniz_oxide = "0.9" +craft-pdf = { workspace = true, features = ["write"] } quick-xml = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). acadrust = "0.6.5" tiny-skia = { workspace = true } +# Reads plotted PDFs back. +craft-pdf = { workspace = true, features = ["read"] } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index fd2742c17..a4d9f5ea7 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -1,11 +1,12 @@ -//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file -//! access, so it runs on the web too). +//! Vector PDF plotting (PLOT / EXPORTPDF), written with craft-pdf's PDF writer (krilla; pure +//! Rust, no file access, so it runs on the web too). //! //! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given //! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise). -//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white -//! paper, and line widths come from lineweights when enabled. The page setup's plot style -//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights. +//! Lines are stroked paths, fills are filled paths, colours are RGB with colour 7 printing +//! black on white paper, and line widths come from lineweights when enabled. The page setup's +//! plot style table (or the `plotStyleTable` option) sets the printed colours, linetypes and +//! lineweights. //! //! Several sheets go into one file ([`pdf_sheets`], the `layouts` option), each page labelled //! with its layout name. Drawing layers become PDF layers (optional content groups) that a PDF @@ -19,6 +20,19 @@ use cadcraft_geom::{Bounds2, Vec2}; use cadcraft_render::{Kind, Sheet, clip, paper}; use serde_json::Value; +use craft_pdf::write::backend as kr; +use craft_pdf::write::{self, Metadata, WriteError, Writer}; +use kr::action::LinkAction; +use kr::annotation::{Annotation, LinkAnnotation, Target}; +use kr::error::KrillaError; +use kr::geom::{Path, PathBuilder, Point, Rect, Transform}; +use kr::num::NormalizedF32; +use kr::optional_content::OcgId; +use kr::page::{PageLabel, PageSettings}; +use kr::paint::{Fill, FillRule, LineCap, LineJoin, Stroke}; +use kr::surface::Surface; +use kr::text::{GlyphId, KrillaGlyph}; + use crate::{IoError, Result}; mod fonts; @@ -31,9 +45,13 @@ const MAX_SHEET_MM: f64 = 5080.0; pub const MAX_PAGES: usize = 256; /// Most PDF layers in one file; objects on further layers print without one. const MAX_LAYERS: usize = 4096; +/// Longest PDF layer name, in characters. +const MAX_LAYER_NAME: usize = 1000; /// Most link annotations in one file, and the longest link target (bytes). const MAX_LINKS: usize = 10_000; const MAX_URL: usize = 2048; +/// Longest title and page label written (characters). +const MAX_TEXT: usize = 1000; /// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for /// model space). @@ -64,8 +82,8 @@ pub struct PdfOptions { pub window: Option, /// Drawing layers become PDF layers (optional content groups). Default on. pub layers: bool, - /// With `layers`: objects on layers that are off are included on PDF layers that start - /// hidden (frozen layers are never included). Default off: they aren't plotted. + /// With `layers`: objects on layers that are off are plotted on PDF layers that start + /// hidden (frozen layers are never plotted). Default off: they aren't plotted. pub include_off_layers: bool, /// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers /// on the sheet becomes a link annotation. @@ -356,23 +374,121 @@ pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) -> if items.len() > MAX_PAGES { return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF"))); } - // Layers that are off plot on PDF layers that start hidden: draw them from a copy with them on. - let with_off; - let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) { - let mut c = d.clone(); - for l in c.layers.iter_mut().filter(|l| !l.frozen) { - l.on = true; + match write_pdf(d, items, o, o.text_as_geometry) { + // A font the PDF writer can't embed after all, or one that makes it fail internally: + // plot the text as outlines instead. Outlines embed no font, so this runs at most once. + Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io), + r => r.map_err(Failure::into_io), + } +} + +/// Whether a writer failure may come from embedding a font: a font the writer refused, or a +/// panic inside the writer (caught by `guard`), which a malformed font can cause. +fn font_failure(e: &WriteError) -> bool { + match e { + WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)), + WriteError::BackendPanic(_) => true, + _ => false, + } +} + +/// Why writing failed: our own error, or the PDF writer's. +enum Failure { + Io(IoError), + Write(WriteError), +} + +impl From for Failure { + fn from(e: IoError) -> Self { + Failure::Io(e) + } +} + +impl From for Failure { + fn from(e: WriteError) -> Self { + Failure::Write(e) + } +} + +impl Failure { + fn into_io(self) -> IoError { + match self { + Failure::Io(e) => e, + Failure::Write(e) => IoError::Format(format!("PDF export failed: {e}")), } - with_off = c; - &with_off - } else { - d - }; - let links = all_links(d, o); - let pages = items.iter().map(|(s, po)| sheet(drawn, s, po, &links)).collect::>>()?; - let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() }; - 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)) + } +} + +/// Write the file, one sheet at a time: each sheet's display list is built, written as its page +/// and dropped before the next. +fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result, Failure> { + write::guard(|| { + // Layers that are off plot on PDF layers that start hidden: draw them from a copy with + // them on. + let with_off; + let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) { + let mut c = d.clone(); + for l in c.layers.iter_mut().filter(|l| !l.frozen) { + l.on = true; + } + with_off = c; + &with_off + } else { + d + }; + let links = all_links(d, o); + let title = if o.title.is_empty() { items.first().map(|(s, _)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() }; + let metadata = Metadata { + creator: "CADCraft".into(), + producer: "CADCraft".into(), + title: Some(title.chars().take(MAX_TEXT).collect()), + ..Metadata::default() + }; + let settings = kr::SerializeSettings { + compress_content_streams: o.compress, + xmp_metadata: false, + enable_tagging: false, + ..kr::SerializeSettings::default() + }; + let mut writer = Writer::new(settings, metadata); + let mut fonts = fonts::Fonts::new(!text_as_geometry); + let mut groups = Groups::default(); + for (s, po) in items { + let page = sheet(drawn, s, po, &links)?; + let doc = writer.document_mut(); + // Groups are added to the document, so they must exist before the page starts. + groups.add(doc, d, &page.layers); + write_page(doc, &page, &groups, &mut fonts)?; + } + if items.len() == 1 && !groups.ids.is_empty() { + writer.document_mut().set_optional_content_page_mode(true); + } + Ok(writer.finish()?) + }) +} + +/// The PDF layers (optional content groups) of a file: one per drawing layer used, shared by +/// all pages, at most [`MAX_LAYERS`]. +#[derive(Default)] +struct Groups { + ids: std::collections::HashMap, +} + +impl Groups { + /// Add groups for the drawing layers in `layers` that don't have one yet, named after the + /// layer and visible when the layer is on in `d`, the drawing as given (not the copy that + /// turns layers that are off on to plot them). + fn add(&mut self, doc: &mut kr::Document, d: &Drawing, layers: &[u32]) { + for l in layers { + if self.ids.len() >= MAX_LAYERS || self.ids.contains_key(l) { + continue; + } + let Some(layer) = d.layers.get(*l as usize) else { continue }; + let name: String = layer.name.chars().take(MAX_LAYER_NAME).collect(); + let id = doc.add_optional_content_group(name, layer.on); + self.ids.insert(*l, id); + } + } } /// One plotted sheet, ready to be written as a page. @@ -383,7 +499,8 @@ struct SheetPage { list: cadcraft_render::DisplayList, map: Map, clip_pt: Bounds2, - /// The drawing layers (indices in the layer table) of the content's `/L` marks. + /// The drawing layers (indices in the layer table) drawn on, in order of first use: the + /// candidates for PDF layers. layers: Vec, /// Link annotations: rectangle in points and target. links: Vec<(Bounds2, String)>, @@ -556,125 +673,170 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option 0.0 { s } else { 1.0 } } -/// A number for a content stream: short, finite, no exponent. -fn num(out: &mut String, v: f64) { - let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 }; - let mut s = format!("{v:.3}"); - if s.contains('.') { - while s.ends_with('0') { - s.pop(); - } - if s.ends_with('.') { - s.pop(); - } - } - if s == "-0" { - s = "0".into(); - } - out.push_str(&s); +/// Largest coordinate written (points); a primitive reaching further is clipped to the plot +/// area first. The PDF writer's geometry is `f32`. +const MAX_COORD: f64 = 1.0e7; + +/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range. +fn coord(p: Vec2) -> Option<(f32, f32)> { + let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD; + (ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32)) } -fn pt(out: &mut String, p: Vec2) { - num(out, p.x); - out.push(' '); - num(out, p.y); +/// A rectangle path between two corners (points), if it has an area. +fn rect_path(min: Vec2, max: Vec2) -> Option { + let (x0, y0) = coord(min)?; + let (x1, y1) = coord(max)?; + let mut pb = PathBuilder::new(); + pb.push_rect(Rect::from_ltrb(x0, y0, x1, y1)?); + pb.finish() } -fn color(out: &mut String, c: Rgb, op: &str) { - for v in [c.0, c.1, c.2] { - num(out, f64::from(v) / 255.0); - out.push(' '); +/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its +/// own, and this is about the thinnest mark that still prints. +const HAIRLINE_DOT_PT: f32 = 0.25; + +/// A circle path (points) around `(x, y)`: four cubic arcs. +fn circle_path(x: f32, y: f32, r: f32) -> Option { + // Control-point distance of a quarter-circle cubic, as a fraction of the radius. + let k = r * 0.552_284_8; + let mut pb = PathBuilder::new(); + pb.move_to(x + r, y); + pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r); + pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y); + pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r); + pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y); + pb.close(); + pb.finish() +} + +/// Write one sheet as a page: its size, label, link annotations and marks. +fn write_page(doc: &mut kr::Document, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> { + let (w, h) = (p.media.x as f32, p.media.y as f32); + let label = PageLabel::new(None, Some(p.label.chars().take(MAX_TEXT).collect()), None); + let settings = PageSettings::from_wh(w, h).ok_or_else(|| IoError::Format("the sheet has no area".into()))?.with_page_label(label); + let mut page = doc.start_page_with(settings); + for (r, url) in &p.links { + let Some(uri) = uri(url) else { continue }; + // Annotation rectangles are given top-down, like the writer's page surface. + let Some(rect) = Rect::from_ltrb(r.min.x as f32, h - r.max.y as f32, r.max.x as f32, h - r.min.y as f32) else { continue }; + let link = LinkAnnotation::new(rect, Target::Action(LinkAction::new(uri).into())); + page.add_annotation(Annotation::new_link(link, None)); } - out.push_str(op); - out.push('\n'); -} - -/// The page's marks; those of primitives on `layers` are marked as optional content -/// (`/OC /L BDC` … `EMC`). -/// -/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its -/// embedded font (`/F`), placed by a text matrix. -/// -/// Transparent primitives are drawn with a graphics state of their alpha (`/T gs`); -/// the transparencies used are returned for the page's `/ExtGState` (see [`ext_gstates`]). A -/// list without transparency writes no graphics states. -fn content_stream( - list: &cadcraft_render::DisplayList, - map: &Map, - clip_pt: &Bounds2, - layers: &[u32], - texts: &std::collections::HashMap, -) -> (String, Vec) { - let marked: std::collections::HashSet = layers.iter().copied().collect(); - let white = Rgb(255, 255, 255); - let mut c = String::new(); - c.push_str("q\n1 J 1 j\n"); + let mut surface = page.surface(); + // The surface's y axis points down; flip it so we draw in PDF points (y up). + surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h)); // Clip to the plot area. - pt(&mut c, clip_pt.min); - c.push(' '); - num(&mut c, clip_pt.width()); - c.push(' '); - num(&mut c, clip_pt.height()); - c.push_str(" re W n\n"); - let mut stroke: Option = None; - let mut fill: Option = None; - let mut width: Option = None; - let mut pending = false; - let flush = |c: &mut String, pending: &mut bool| { - if *pending { - c.push_str("S\n"); - *pending = false; - } - }; - // 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; - // The transparency (percent) marks are drawn with, and those used on the page. - let mut alpha: u8 = 0; - let mut alphas: Vec = Vec::new(); - 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; - } + let mut drawn = Ok(()); + if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) { + surface.push_clip_path(&clip, &FillRule::NonZero); + drawn = marks(&mut surface, p, groups, fonts); + surface.pop(); + } + surface.pop(); + surface.finish(); + page.finish(); + drawn +} + +/// What consecutive strokes share to be painted as one path: colour, width (points) and +/// transparency (percent). +#[derive(Clone, Copy, PartialEq)] +struct StrokeStyle { + rgb: Rgb, + width: f32, + transparency: u8, +} + +/// Opacity of a transparency in percent. +fn opacity(t: u8) -> NormalizedF32 { + NormalizedF32::new(f32::from(100 - t.min(cadcraft_render::MAX_TRANSPARENCY)) / 100.0).unwrap_or(NormalizedF32::ONE) +} + +fn paint(c: Rgb) -> kr::paint::Paint { + kr::color::rgb::Color::new(c.0, c.1, c.2).into() +} + +fn fill_path(s: &mut Surface<'_>, path: &Path, rgb: Rgb, t: u8) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(t), rule: FillRule::NonZero })); + s.draw_path(path); +} + +/// Paint the strokes collected so far as one path. +fn flush(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>) { + let Some((style, pb)) = batch.take() else { return }; + let Some(path) = pb.finish() else { return }; + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: paint(style.rgb), + width: style.width, + line_cap: LineCap::Round, + line_join: LineJoin::Round, + opacity: opacity(style.transparency), + ..Stroke::default() + })); + s.draw_path(&path); +} + +/// The PDF layer of the following marks changed from `from` to `to` (`None`: no layer): end +/// the old one's marked content and start the new one's. +fn layer_changed(s: &mut Surface<'_>, from: Option, to: Option) -> std::result::Result<(), Failure> { + if from.is_some() { + s.end_optional_content().map_err(krilla_failure)?; + } + if let Some(id) = to { + s.start_optional_content(id).map_err(krilla_failure)?; + } + Ok(()) +} + +fn krilla_failure(e: KrillaError) -> Failure { + Failure::Io(IoError::Format(format!("PDF export failed: {e}"))) +} + +/// The page's marks, clipped to the plot area by the caller. Those of primitives on a layer +/// with a PDF layer in `groups` are marked as its optional content. +/// +/// Consecutive strokes with the same colour, width and transparency are one path; a +/// transparent primitive is stroked on its own (one path is painted once), so where +/// primitives overlap they build up, as in PNG plots and on screen. Block contents and +/// viewport contents share their reference's handle, so this goes by primitive, not by object. +/// Each filled primitive is one path of its triangles. +/// +/// Primitives that stand for a glyph in an embeddable font (see [`fonts::Fonts`]) are written +/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one +/// em). +fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> { + let (list, map) = (&p.list, &p.map); + let texts = fonts::texts(list); + let white = Rgb(255, 255, 255); + let mut batch: Option<(StrokeStyle, PathBuilder)> = None; + let mut layer: Option = None; + for (i, prim) in list.prims.iter().enumerate() { + let want = list.prim_layers.get(i).and_then(|l| groups.ids.get(l)).copied(); if want != layer { - flush(&mut c, &mut pending); - if layer.is_some() { - c.push_str("EMC\n"); - } - if let Some(l) = want { - let _ = writeln!(c, "/OC /L{l} BDC"); - } + flush(s, &mut batch); + layer_changed(s, layer, want)?; layer = want; } - let rgb = p.display_rgb(white); - let raw = list.points(p); + let rgb = prim.display_rgb(white); + let raw = list.points(prim); if raw.iter().any(|q| !q.is_finite()) { continue; } // A wipeout's mask always hides what is under it. - let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) }; - if t != alpha { - flush(&mut c, &mut pending); - let _ = writeln!(c, "/T{t} gs"); - if !alphas.contains(&t) { - alphas.push(t); - } - alpha = t; - } - let w = f64::from(p.lw) * PT_PER_MM; + let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) }; + let w = f64::from(prim.lw) * PT_PER_MM; let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 }; - match p.kind { + match prim.kind { Kind::Polyline | Kind::Infinite { .. } => { - let pts: Vec = match p.kind { + let pts: Vec = match prim.kind { Kind::Infinite { ray } => match (raw.first(), raw.get(1)) { (Some(base), Some(dir)) => { let a = map.pt(*base); let dir_pt = map.pt(*base + *dir) - a; - match clip::clip_infinite(a, dir_pt, ray, clip_pt) { + match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) { Some((x, y)) => vec![x, y], None => continue, } @@ -686,301 +848,117 @@ fn content_stream( if pts.len() < 2 { continue; } - // A transparent primitive is stroked on its own (one path is painted once), so - // where primitives overlap they build up, as in PNG plots and on screen. Block - // contents and viewport contents share their reference's handle, so this goes - // by primitive, not by object. - if t > 0 { - flush(&mut c, &mut pending); + // With a vertex out of range, keep the pieces inside the plot area, widened so + // a wide stroke just outside it still shows its edge. + let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) }; + let style = StrokeStyle { rgb, width: w as f32, transparency: t }; + if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) { + flush(s, &mut batch); } - if stroke != Some(rgb) || width != Some(w) { - flush(&mut c, &mut pending); - if stroke != Some(rgb) { - color(&mut c, rgb, "RG"); - stroke = Some(rgb); + for run in runs { + let Some(pts) = run.iter().map(|q| coord(*q)).collect::>>() else { continue }; + // The writer leaves out a path without extent, so a zero-length line is + // painted as the round-cap dot its stroke would make. + if let Some(&(x, y)) = pts.first() + && pts.iter().all(|q| *q == (x, y)) + { + flush(s, &mut batch); + let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT }; + if let Some(path) = circle_path(x, y, d / 2.0) { + fill_path(s, &path, rgb, t); + } + continue; } - if width != Some(w) { - num(&mut c, w); - c.push_str(" w\n"); - width = Some(w); + let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new())); + for (j, (x, y)) in pts.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } } } - for (i, q) in pts.iter().enumerate() { - pt(&mut c, *q); - c.push_str(if i == 0 { " m\n" } else { " l\n" }); - } - pending = true; } Kind::Tris | Kind::Mask => { - flush(&mut c, &mut pending); + flush(s, &mut batch); // A wipeout's mask is paper white. - let rgb = if p.kind == Kind::Mask { white } else { rgb }; - if fill != Some(rgb) { - color(&mut c, rgb, "rg"); - fill = Some(rgb); - } - if let Some((font, t)) = text { - glyph(&mut c, &mut text_font, *font, t, map); + let rgb = if prim.kind == Kind::Mask { white } else { rgb }; + if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i)) + && let Some(font) = fonts.font(&text.glyph.face) + { + glyph(s, font, text, map, rgb, t); continue; } - let mut any = false; - for t in raw.as_chunks::<3>().0 { - for (i, q) in t.iter().enumerate() { - pt(&mut c, map.pt(*q)); - c.push_str(if i == 0 { " m\n" } else { " l\n" }); + let mapped: Vec = raw.iter().map(|q| map.pt(*q)).collect(); + // With a vertex out of range, keep the parts inside the plot area. + let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) }; + let mut pb = PathBuilder::new(); + for tri in tris.as_chunks::<3>().0 { + let Some(v) = tri.iter().map(|q| coord(*q)).collect::>>() else { continue }; + for (j, (x, y)) in v.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } } - c.push_str("h\n"); - any = true; + pb.close(); } - if any { - c.push_str("f\n"); + if let Some(path) = pb.finish() { + fill_path(s, &path, rgb, t); } } Kind::Point => { let Some(q) = raw.first() else { continue }; - flush(&mut c, &mut pending); - if fill != Some(rgb) { - color(&mut c, rgb, "rg"); - fill = Some(rgb); - } + flush(s, &mut batch); let half = (w.max(0.5)) / 2.0; - pt(&mut c, map.pt(*q) - Vec2::new(half, half)); - c.push(' '); - num(&mut c, half * 2.0); - c.push(' '); - num(&mut c, half * 2.0); - c.push_str(" re f\n"); + let c = map.pt(*q); + if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) { + fill_path(s, &path, rgb, t); + } } } } - flush(&mut c, &mut pending); - if text_font.is_some() { - c.push_str("ET\n"); - } - if layer.is_some() { - c.push_str("EMC\n"); - } - c.push_str("Q\n"); - (c, alphas) + flush(s, &mut batch); + layer_changed(s, layer, None) } -/// The page resources' graphics states for transparencies `alphas` (percent): `/T` -/// sets the stroke and fill alpha (`/CA`, `/ca`). Empty when there are none. -fn ext_gstates(alphas: &[u8]) -> String { - if alphas.is_empty() { - return String::new(); - } - let mut out = String::from(" /ExtGState <<"); - for t in alphas { - let mut a = String::new(); - num(&mut a, f64::from(100 - u32::from((*t).min(cadcraft_render::MAX_TRANSPARENCY))) / 100.0); - let _ = write!(out, " /T{t} << /Type /ExtGState /CA {a} /ca {a} >>"); - } - out.push_str(" >>"); - out -} - -/// 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; +/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one +/// em along each axis). A glyph whose matrix is out of range or collapses is left out. +fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) { + let k = 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(' '); + let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return }; + let det = a * d - b * c; + if !det.is_finite() || det == 0.0 { + return; } - pt(c, map.pt(g.origin)); - let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid); + let text = g.ch.to_string(); + let glyphs = [KrillaGlyph::new(GlyphId::new(u32::from(g.gid)), 0.0, 0.0, 0.0, 0.0, 0..text.len(), None)]; + // The writer sets glyphs upright on a y-down surface: flip the em's y axis back. + s.push_transform(&Transform::from_row(a, b, -c, -d, e, f)); + s.set_stroke(None); + s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(transparency), rule: FillRule::NonZero })); + s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, 1.0, false); + s.pop(); } -/// 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 { - let s: String = s.chars().take(1000).collect(); - if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) { - let mut out = String::from("("); - for ch in s.chars() { - if matches!(ch, '(' | ')' | '\\') { - out.push('\\'); - } - out.push(ch); - } - out.push(')'); - return out; - } - let mut out = String::from("'); - out -} - -/// A link target as a PDF URI string: 7-bit ASCII (other bytes percent-encoded), `(`, `)` and -/// `\` escaped. `None` for script and data URLs, which a plot never carries. -fn uri_string(url: &str) -> Option { +/// A link target as a URI: 7-bit printable ASCII, other bytes percent-encoded. `None` for +/// script and data URLs, which a plot never carries. +fn uri(url: &str) -> Option { let url = url.trim(); let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase(); if url.is_empty() || url.len() > MAX_URL || matches!(scheme.as_str(), "javascript" | "vbscript" | "data") { return None; } - let mut out = String::from("("); + let mut out = String::with_capacity(url.len()); for b in url.bytes() { match b { - b'(' | b')' | b'\\' => { - out.push('\\'); - out.push(char::from(b)); - } 0x21..=0x7E => out.push(char::from(b)), _ => { let _ = write!(out, "%{b:02X}"); } } } - out.push(')'); Some(out) } - -/// A stream object body, Flate-compressed when asked. -fn stream(data: &[u8], compress: bool, extra: &str) -> Vec { - let (data, filter) = if compress { (miniz_oxide::deflate::compress_to_vec_zlib(data, 6), " /Filter /FlateDecode") } else { (data.to_vec(), "") }; - let mut out = format!("<< /Length {}{filter}{extra} >>\nstream\n", data.len()).into_bytes(); - out.extend_from_slice(&data); - out.extend_from_slice(b"\nendstream"); - out -} - -/// PDF objects being written: bodies by object number - 1. -#[derive(Default)] -struct Objects { - bodies: Vec>, -} - -impl Objects { - /// Reserve an object number (its body is set later). - fn reserve(&mut self) -> usize { - self.bodies.push(Vec::new()); - self.bodies.len() - } - fn set(&mut self, n: usize, body: impl Into>) { - if let Some(b) = n.checked_sub(1).and_then(|i| self.bodies.get_mut(i)) { - *b = body.into(); - } - } - fn add(&mut self, body: impl Into>) -> usize { - let n = self.reserve(); - self.set(n, body); - n - } -} - -/// `n 0 R` references, space-separated. -fn refs(ids: impl IntoIterator) -> String { - ids.into_iter().map(|i| format!("{i} 0 R")).collect::>().join(" ") -} - -/// 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], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec { - let mut objs = Objects::default(); - let catalog = objs.reserve(); - let tree = objs.reserve(); - // Optional content groups shared by all pages, by layer index: (layer, object). - 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, String)> = Vec::new(); - for p in pages { - let page = objs.reserve(); - let texts = fonts.texts(&p.list); - let (marks, alphas) = content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts); - let content = objs.add(stream(marks.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 }; - let id = match ocg_ids.get(l) { - Some(id) => *id, - None => { - let id = objs.add(format!("<< /Type /OCG /Name {} >>", pdf_string(&layer.name))); - ocg_ids.insert(*l, id); - ocgs.push((*l, id)); - id - } - }; - let _ = write!(props, " /L{l} {id} 0 R"); - } - let mut annots = Vec::new(); - for (r, url) in &p.links { - let Some(uri) = uri_string(url) else { continue }; - let mut rect = String::new(); - pt(&mut rect, r.min); - rect.push(' '); - pt(&mut rect, r.max); - annots.push(objs.add(format!("<< /Type /Annot /Subtype /Link /Rect [{rect}] /Border [0 0 0] /A << /S /URI /URI {uri} >> >>"))); - } - let mut mb = String::new(); - pt(&mut mb, p.media); - let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) }; - page_dicts.push((page, used, props, ext_gstates(&alphas), 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, gstates, rest) in page_dicts { - let mut res = String::new(); - if !props.is_empty() { - let _ = write!(res, " /Properties <<{props} >>"); - } - res.push_str(&gstates); - 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() { - // Optional content needs PDF 1.5; the catalog's version overrides the header's. - let off: Vec = ocgs.iter().filter(|(l, _)| d.layers.get(*l as usize).is_some_and(|l| !l.on)).map(|(_, id)| *id).collect(); - let all = refs(ocgs.iter().map(|(_, id)| *id)); - let _ = write!(cat, " /Version /1.5 /OCProperties << /OCGs [{all}] /D << /Name (Layers) /Order [{all}] /OFF [{}] >> >>", refs(off)); - if pages.len() == 1 { - cat.push_str(" /PageMode /UseOC"); - } - } - let labels: Vec = pages.iter().enumerate().map(|(i, p)| format!("{i} << /P {} >>", pdf_string(&p.label))).collect(); - let _ = write!(cat, " /PageLabels << /Nums [{}] >> >>", labels.join(" ")); - objs.set(catalog, cat); - let info = objs.add(format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title))); - let size: usize = objs.bodies.iter().map(Vec::len).sum(); - let mut out: Vec = Vec::with_capacity(size + 64 * objs.bodies.len() + 256); - out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); - let mut offsets = Vec::with_capacity(objs.bodies.len()); - for (i, body) in objs.bodies.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(body); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1); - for o in &offsets { - let _ = writeln!(x, "{o:010} 00000 n "); - } - let _ = write!(x, "trailer\n<< /Size {} /Root {catalog} 0 R /Info {info} 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1); - out.extend_from_slice(x.as_bytes()); - out -} diff --git a/crates/io/src/pdf/fonts.rs b/crates/io/src/pdf/fonts.rs index 56b3d0841..179cb7273 100644 --- a/crates/io/src/pdf/fonts.rs +++ b/crates/io/src/pdf/fonts.rs @@ -1,25 +1,22 @@ //! 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. +//! written as text in their font, which the PDF writer embeds as a subset with a `ToUnicode` +//! map, so the text can be selected, searched and copied. //! //! 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 std::collections::HashMap; -use cadcraft_fonts::subset::{self, FontInfo}; +use cadcraft_fonts::subset::{self, Embedding}; use cadcraft_fonts::ttf::Face; use cadcraft_render::{DisplayList, PlotText}; - -use super::{Objects, num, refs, stream}; +use craft_pdf::write::backend::text::Font; /// 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; +/// Most font file data embedded from in one file (bytes of the font files the subsets are +/// taken from). +const MAX_FONT_BYTES: usize = 256 << 20; /// A face's identity: its loaded file and face index. type FaceKey = (usize, usize, u32); @@ -28,153 +25,45 @@ 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)] +/// The fonts of a document, decided face by face as glyphs are written. pub(super) struct Fonts { - fonts: Vec, - by_face: HashMap>, + /// Off: every glyph stays an outline. + enabled: bool, + /// Each face seen, with its font when it is embedded. The face is kept so its key isn't + /// reused by another file loaded at the same address. + by_face: HashMap)>, + count: usize, + bytes: usize, } 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); - } - } + pub(super) fn new(enabled: bool) -> Fonts { + Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 } + } + + /// The font a glyph of `face` is written in; `None`: draw its outline. + pub(super) fn font(&mut self, face: &Face) -> Option { + if !self.enabled { + return None; } - 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); - } - } + let k = key(face); + if let Some((_, f)) = self.by_face.get(&k) { + return f.clone(); } - 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(' '); + let len = face.bytes.len(); + let font = (self.count < MAX_FONTS && self.bytes.saturating_add(len) <= MAX_FONT_BYTES && subset::embedding(face) == Embedding::Allowed) + .then(|| Font::new(face.bytes.clone().into(), face.index)) + .flatten(); + if font.is_some() { + self.count += 1; + self.bytes = self.bytes.saturating_add(len); } - num(&mut bbox, v.round()); + self.by_face.insert(k, (face.clone(), font.clone())); + font } - 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 +/// The text glyphs of a list that stand for a primitive of it, by primitive. +pub(super) fn texts(list: &DisplayList) -> HashMap { + list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0).map(|t| (t.prim, t)).collect() } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 1a2f0f86b..7b8f38385 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -4,6 +4,9 @@ use cadcraft_geom::{PolyVertex, Vec2, Vec3}; use crate::*; +#[path = "../tests/support/pdf_read.rs"] +mod pdf_read; + fn sample() -> Drawing { let mut d = Drawing::new_imperial(); d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() }); @@ -418,50 +421,22 @@ fn hostile_export_window_is_an_error() { assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok()); } -/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`. +/// Read a PDF back (its structure checked by the reader) and return the first page's decoded +/// content. fn check_pdf(bytes: &[u8]) -> String { - assert!(bytes.starts_with(b"%PDF-1.4")); - let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat); - let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat); - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string(); - assert!(tail.trim_end().ends_with("%%EOF")); - let sx = rfind(b"startxref\n").unwrap(); - let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string(); - let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap(); - assert!(bytes[xref_off..].starts_with(b"xref\n")); - let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string(); - let mut lines = xref.lines().skip(1); - let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); - let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count(); - assert_eq!(count, objs + 1, "xref size = objects + free entry"); - assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs); - let entries: Vec<&str> = lines.take(count).collect(); - assert!(entries[0].starts_with("0000000000 65535 f")); - for (i, e) in entries.iter().enumerate().skip(1) { - let off: usize = e[..10].parse().unwrap(); - assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset"); - } - assert!(xref.contains(&format!("/Size {count}"))); - // The content stream's /Length matches its data. - let sp = find(b"/Length ").unwrap(); - let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string(); - let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap(); - let start = find(b"stream\n").unwrap() + 7; - assert!(bytes[start + len..].starts_with(b"\nendstream")); - let compressed = find(b"/FlateDecode").is_some(); - let data = &bytes[start..start + len]; - if compressed { - String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap() - } else { - String::from_utf8(data.to_vec()).unwrap() - } + assert!(String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).trim_end().ends_with("%%EOF")); + let doc = pdf_read::read(bytes); + assert!(!doc.pages.is_empty()); + doc.content().to_string() } fn media_box(bytes: &[u8]) -> (f64, f64) { - let text = String::from_utf8_lossy(bytes).to_string(); - let i = text.find("/MediaBox [0 0 ").unwrap() + 15; - let v: Vec = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect(); - (v[0], v[1]) + pdf_read::read(bytes).media() +} + +/// The line widths (`w`) the content sets. +fn widths(content: &str) -> Vec { + pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect() } #[test] @@ -471,16 +446,17 @@ fn pdf_model_fitted_to_sheet() { let content = check_pdf(&bytes); let (w, h) = media_box(&bytes); assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}"); - assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n")); + assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0); // Walls layer is red; colour 7 prints black. assert!(content.contains("1 0 0 RG")); assert!(content.contains("0 0 0 RG")); // Uncompressed output is plain text and also valid. let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap(); let c2 = check_pdf(&raw); - assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode")); + assert!(!pdf_read::read(&raw).pages[0].compressed); + assert!(pdf_read::read(&bytes).pages[0].compressed); assert_eq!(media_box(&raw), (612.0, 792.0)); - assert!(c2.contains("re W n")); + assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}"); // write() picks PDF by extension. assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF")); } @@ -513,11 +489,12 @@ fn pdf_layout_one_to_one_with_viewport() { // ANSI A landscape (inches → points). assert_eq!(media_box(&bytes), (792.0, 612.0)); // Viewport border at 1:1: x from 1in to 9in = 72pt..648pt. - assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}"); + let corner = |o: &pdf_read::Op| matches!(o.op.as_str(), "m" | "l") && (o.nums[0] - 72.0).abs() < 1e-6 && (o.nums[1] - 72.0).abs() < 1e-6; + assert!(pdf_read::drawn(&content).iter().any(corner), "{content}"); // Lineweights on: default 0.25 mm = 0.709 pt. - assert!(content.contains("0.709 w")); + assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}"); let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap(); - assert!(check_pdf(&off).contains("0 w")); + assert!(widths(&check_pdf(&off)).contains(&0.0)); assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err()); assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err()); } @@ -2438,15 +2415,16 @@ fn pdf_plots_transparency_only_when_asked() { }; let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap(); let content = check_pdf(&on); - let text = String::from_utf8_lossy(&on).to_string(); - assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}"); + // One graphics state: fill alpha 0.5 (strokes stay opaque). + assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]); // The line is opaque, the solid at half alpha. - assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}"); + let ops = pdf_read::ops(&content); + assert!(ops.windows(2).any(|w| w[0].op == "gs" && w[1].op == "rg" && w[1].nums == [1.0, 0.0, 0.0]), "{content}"); + assert_eq!(pdf_read::count(&content, "gs"), 1, "{content}"); // Off (the default, and explicitly): no graphics states, and the same bytes either way. let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap(); assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap()); - let off_text = String::from_utf8_lossy(&off).to_string(); - assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n")); + assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0); // On, but nothing transparent: the same bytes as off. let opaque = transparent_over_line(0); assert_eq!( @@ -2465,13 +2443,13 @@ fn layout_page_setup_plot_transparency_is_used() { let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() }; d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap(); let raw = serde_json::json!({"compress": false}); - assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState")); + let alphas = |d: &Drawing, o: &serde_json::Value| pdf_read::read(&plot(d, &paper, o).unwrap()).pages[0].alphas.clone(); + assert!(alphas(&d, &raw).is_empty()); d.layouts[0].page.plot_transparency = true; - let text = String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).to_string(); - assert!(text.contains("/T90 << /Type /ExtGState /CA 0.1 /ca 0.1 >>"), "{text}"); + let on = alphas(&d, &raw); + assert!(on.iter().any(|(_, f)| (f - 0.1).abs() < 1e-6), "the faint solid fills at alpha 0.1: {on:?}"); // The plot's own option wins over the page setup. - let off = serde_json::json!({"compress": false, "plotTransparency": false}); - assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState")); + assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty()); } #[test] @@ -2526,11 +2504,14 @@ fn pdf_strokes_overlapping_transparent_objects_one_by_one() { }; // 50 %: each line is its own stroke, so they build up where they cross. let glass = content(&crossing(50), true); - assert!(glass.contains("/T50 gs"), "{glass}"); - assert_eq!(glass.matches("S\n").count(), 2, "{glass}"); + assert!(pdf_read::count(&glass, "gs") > 0, "{glass}"); + let page = + pdf_read::read(&plot(&crossing(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()).pages.remove(0); + assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas); + assert_eq!(pdf_read::count(&glass, "S"), 2, "{glass}"); // Opaque: one path as before, whether the option is on or off. let opaque = content(&crossing(0), true); - assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}"); + assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}"); assert_eq!(opaque, content(&crossing(0), false)); // Transparency off: the transparent layer plots as before too. assert_eq!(content(&crossing(50), false), opaque); @@ -2567,11 +2548,15 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() { check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap()) }; let half = content(&crossing_block(50), true); - assert!(half.contains("/T50 gs"), "{half}"); - assert_eq!(half.matches("S\n").count(), 2, "{half}"); + assert!(pdf_read::count(&half, "gs") > 0, "{half}"); + let page = pdf_read::read(&plot(&crossing_block(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()) + .pages + .remove(0); + assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas); + assert_eq!(pdf_read::count(&half, "S"), 2, "{half}"); // Opaque: one path, the same with the option on or off. let opaque = content(&crossing_block(0), true); - assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}"); + assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}"); assert_eq!(opaque, content(&crossing_block(0), false)); assert_eq!(content(&crossing_block(50), false), opaque); } @@ -2616,6 +2601,61 @@ fn drawing_without_insunits_stays_unitless() { assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into())); } +#[test] +fn pdf_clips_a_primitive_beyond_the_coordinate_range() { + let mut d = Drawing::new_metric(); + let paper = Space::Paper("Layout1".into()); + let v = |x: f64, y: f64| Vec3::new(x, y, 0.0); + // 4e7 mm at 1:1 is about 1.1e8 pt, past the range the writer's f32 geometry takes. + d.add(&paper, Common::default(), EntityKind::Line(Line { a: v(10.0, 20.0), b: v(4.0e7, 20.0) })).unwrap(); + d.add(&paper, Common::default(), EntityKind::Solid(Solid { corners: [v(10.0, 50.0), v(4.0e7, 50.0), v(10.0, 60.0), v(10.0, 60.0)] })).unwrap(); + let bytes = plot(&d, &paper, &serde_json::json!({"fit": false, "scale": 1.0, "compress": false})).unwrap(); + let content = check_pdf(&bytes); + let clip = pdf_read::clip_rect(&content).unwrap(); + // The x extent of what each paint operator paints. + let (mut xs, mut painted) = (Vec::new(), Vec::new()); + for o in pdf_read::drawn(&content) { + match o.op.as_str() { + "m" | "l" => xs.push(o.nums[0]), + "S" | "f" => painted.push((o.op, xs.drain(..).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x))))), + _ => {} + } + } + let (stroke, fill) = (painted.iter().find(|p| p.0 == "S"), painted.iter().find(|p| p.0 == "f")); + // Both run from inside the plot area to its right edge: the stroke to just past it. + let (_, (lo, hi)) = stroke.expect("the line is drawn"); + assert!(*lo < clip[2] && *hi > clip[2] && *hi < clip[2] + 5.0, "line from {lo} to {hi}, clip {clip:?}"); + let (_, (lo, hi)) = fill.expect("the solid is filled"); + assert!(*lo < clip[2] && (*hi - clip[2]).abs() < 1e-3, "fill from {lo} to {hi}, clip {clip:?}"); +} + +#[test] +fn pdf_plots_a_zero_length_line_as_a_dot() { + let mut d = Drawing::new_metric(); + let red = |lw| Common { color: Color::Index(1), lineweight: lw, ..Common::default() }; + // Points at 1:1 on A4, so the dot sits away from the clip edges. + let at = Vec3::new(50.0, 50.0, 0.0); + d.add(&Space::Model, red(Lineweight::Mm100(100)), EntityKind::Line(Line { a: at, b: at })).unwrap(); + // The x extent of what the content fills in red: the dot's diameter. + let red_fill_width = |o: serde_json::Value| { + let content = check_pdf(&plot(&d, &Space::Model, &o).unwrap()); + let ops = pdf_read::ops(&content); + let start = ops.iter().position(|o| o.op == "rg" && o.nums == [1.0, 0.0, 0.0]).expect("a red fill"); + let end = start + ops[start..].iter().position(|o| o.op == "f").expect("filled"); + assert!(ops[start..end].iter().filter(|o| o.op == "c").count() == 4, "{content}"); + let xs: Vec = + ops[start..end].iter().filter(|o| matches!(o.op.as_str(), "m" | "c")).flat_map(|o| o.nums.iter().step_by(2).copied()).collect(); + xs.iter().copied().fold(f64::MIN, f64::max) - xs.iter().copied().fold(f64::MAX, f64::min) + }; + let opts = |lineweights: bool| serde_json::json!({"paper": "A4", "fit": false, "scale": 1.0, "compress": false, "lineweights": lineweights}); + // 1 mm lineweight: a dot 1 mm across. + let w = red_fill_width(opts(true)); + assert!((w - 72.0 / 25.4).abs() < 0.01, "{w}"); + // A hairline still leaves a mark. + let w = red_fill_width(opts(false)); + assert!((w - 0.25).abs() < 0.01, "{w}"); +} + /// DXF text as (code, value) pairs. fn dxf_pairs(text: &str) -> Vec<(String, String)> { let lines: Vec<&str> = text.lines().collect(); diff --git a/crates/io/tests/it/layout_paper_units.rs b/crates/io/tests/it/layout_paper_units.rs index f707a782f..b44d2e231 100644 --- a/crates/io/tests/it/layout_paper_units.rs +++ b/crates/io/tests/it/layout_paper_units.rs @@ -44,9 +44,9 @@ fn a_millimetre_layout_plots_in_millimetres_whatever_measurement_says() { // The frame lands 10 mm (28.35 pt) inside the page, not 10 inches. let bytes = pdf(&d, &Space::Paper("SHEET".into()), &PdfOptions { compress: false, ..PdfOptions::default() }).unwrap(); - let text = String::from_utf8_lossy(&bytes); + let doc = crate::pdf_read::read(&bytes); let xs: Vec = - text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect(); + crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect(); assert!(!xs.is_empty(), "no path in the PDF"); let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(*x), b.max(*x))); assert!((lo - 28.35).abs() < 0.1 && (hi - 813.54).abs() < 0.1, "frame from {lo} to {hi} pt"); diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 1b1462ae5..e6382aec8 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -45,6 +45,8 @@ mod paperview_marker; mod passthrough_image_roundtrip; mod pdf_followups; mod pdf_layers; +#[path = "../support/pdf_read.rs"] +mod pdf_read; mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; diff --git a/crates/io/tests/it/pdf_followups.rs b/crates/io/tests/it/pdf_followups.rs index 198e6f058..e13eb2937 100644 --- a/crates/io/tests/it/pdf_followups.rs +++ b/crates/io/tests/it/pdf_followups.rs @@ -12,18 +12,22 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) { d.add(&Space::Model, Common::default(), k).unwrap(); } -fn pdf_text(d: &Drawing, opts: Value) -> String { +fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc { let mut o = json!({"compress": false}); if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) { m.extend(e.clone()); } - String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned() + crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap()) } -/// The plot area's clip rectangle (`x y w h re W n`) of the first page. -fn clip(pdf: &str) -> Vec { - let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or(""); - line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect() +/// The plot area's clip rectangle (`x y w h`) of the first page. +fn clip(doc: &crate::pdf_read::Doc) -> Vec { + crate::pdf_read::clip_rect(doc.content()).map(|r| vec![r[0], r[1], r[2] - r[0], r[3] - r[1]]).unwrap_or_default() +} + +/// The link targets of the first page. +fn uris(doc: &crate::pdf_read::Doc) -> Vec { + doc.pages[0].links.iter().map(|l| l.uri.clone()).collect() } #[test] @@ -32,17 +36,17 @@ fn model_space_pdf_uses_the_device_margins() { line(&mut d, (0.0, 0.0), (100.0, 50.0)); let mm = 72.0 / 25.4; // No device: the page setup's printable area, as before. - let plain = clip(&pdf_text(&d, json!({}))); + let plain = clip(&pdf_doc(&d, json!({}))); assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}"); // A device paper with 20 mm margins: the printable area starts 20 mm in. - let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]}))); + let r = clip(&pdf_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]}))); assert_eq!(r.len(), 4, "{r:?}"); assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}"); let (w, h) = (r[2].min(r[3]), r[2].max(r[3])); assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}"); // Hostile margins are ignored. for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] { - assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}"); + assert_eq!(clip(&pdf_doc(&d, json!({"margins": bad}))), plain, "{bad}"); } } @@ -107,13 +111,11 @@ fn pe_url_hyperlinks_become_link_annotations() { let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]); assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links"); let read = read_dxf(text.as_bytes()).unwrap(); - let pdf = pdf_text(&read, json!({})); - assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}"); - assert!(pdf.contains("https://example.com/plan")); - assert!(!pdf.contains("javascript") && !pdf.contains("data:text")); + let pdf = uris(&pdf_doc(&read, json!({}))); + assert_eq!(pdf, ["https://example.com/plan"], "only the web link"); // Turned off, and given explicitly for the same object (the explicit one wins). - assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0); + assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty()); let first = read.model.iter().next().unwrap().handle.hex(); - let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]})); - assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}"); + let own = uris(&pdf_doc(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}))); + assert!(own.contains(&"https://example.org/own".to_string()) && !own.contains(&"https://example.com/plan".to_string()), "{own:?}"); } diff --git a/crates/io/tests/it/pdf_layers.rs b/crates/io/tests/it/pdf_layers.rs index ae8d916c1..f6da233ca 100644 --- a/crates/io/tests/it/pdf_layers.rs +++ b/crates/io/tests/it/pdf_layers.rs @@ -1,6 +1,6 @@ //! PDF export with layers, several sheets and hyperlinks (issue #560): drawing layers become //! optional content groups, `layouts` plots several sheets into one file with page labels, and -//! linked entities get link annotations. The tests parse the written objects. +//! linked entities get link annotations. The tests read the written file back. use cadcraft_color::Color; use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport}; @@ -9,57 +9,19 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets}; use cadcraft_io::{PdfOptions, plot}; use serde_json::json; -/// The objects of a PDF by number, after checking the cross-reference table points at them. -fn objects(bytes: &[u8]) -> Vec<(usize, String)> { - assert!(bytes.starts_with(b"%PDF-1.")); - // Byte offsets stay valid: non-ASCII bytes (the binary comment) become `?`. - let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect(); - let sx = text.rfind("startxref\n").expect("startxref"); - let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap(); - assert!(text[xref..].starts_with("xref\n")); - let mut lines = text[xref..].lines().skip(1); - let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); - let entries: Vec<&str> = lines.take(count).collect(); - assert_eq!(entries.len(), count); - let mut out = Vec::new(); - for (i, e) in entries.iter().enumerate().skip(1) { - let off: usize = e[..10].parse().unwrap(); - let head = format!("{i} 0 obj\n"); - assert!(bytes[off..].starts_with(head.as_bytes()), "object {i} offset"); - let body = &text[off + head.len()..]; - let end = body.find("\nendobj\n").expect("endobj"); - out.push((i, body[..end].to_string())); - } - assert!(text.contains(&format!("/Size {count}"))); - out +use crate::pdf_read::{self, Doc}; + +fn read(bytes: &[u8]) -> Doc { + pdf_read::read(bytes) } -fn object(objs: &[(usize, String)], n: usize) -> &str { - &objs.iter().find(|(i, _)| *i == n).unwrap().1 +fn ocg_names(doc: &Doc) -> Vec { + doc.ocgs.iter().map(|g| g.name.clone()).collect() } -/// The object a `/Key n 0 R` entry of `dict` refers to. -fn reference(dict: &str, key: &str) -> usize { - let i = dict.find(&format!("{key} ")).unwrap_or_else(|| panic!("{key} in {dict}")) + key.len() + 1; - dict[i..].split_whitespace().next().unwrap().parse().unwrap() -} - -/// Uncompressed content streams in page order. -fn contents(objs: &[(usize, String)]) -> Vec { - pages(objs).iter().map(|p| object(objs, reference(p, "/Contents")).to_string()).collect() -} - -fn pages(objs: &[(usize, String)]) -> Vec { - objs.iter().filter(|(_, b)| b.starts_with("<< /Type /Page ")).map(|(_, b)| b.clone()).collect() -} - -fn catalog(objs: &[(usize, String)]) -> String { - objs.iter().find(|(_, b)| b.starts_with("<< /Type /Catalog")).unwrap().1.clone() -} - -/// The names of the optional content groups. -fn ocg_names(objs: &[(usize, String)]) -> Vec { - objs.iter().filter_map(|(_, b)| b.strip_prefix("<< /Type /OCG /Name ").map(|r| r.trim_end_matches(" >>").to_string())).collect() +/// The `BDC` marks of a content: the property resource each one names. +fn marks(content: &str) -> Vec { + pdf_read::ops(content).into_iter().filter(|o| o.op == "BDC").filter_map(|o| o.names.get(1).cloned()).collect() } fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle { @@ -109,45 +71,66 @@ fn raw(extra: serde_json::Value) -> serde_json::Value { #[test] fn drawing_layers_become_pdf_layers() { let d = layered(); - let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap()); - let names = ocg_names(&objs); + let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap()); // Plotted layers only, in drawing order of first use; block contents on layer 0 are on - // the block reference's layer. - assert_eq!(names, ["(0)", "(Walls)", "(Doors)"]); - let cat = catalog(&objs); - assert!(cat.contains("/Version /1.5") && cat.contains("/OCProperties") && cat.contains("/OFF []"), "{cat}"); - // Every OCG is listed and switchable. - let ocgs: Vec = objs.iter().filter(|(_, b)| b.starts_with("<< /Type /OCG")).map(|(i, _)| *i).collect(); - for id in &ocgs { - assert!(cat.contains(&format!("{id} 0 R"))); - } + // the block reference's layer. None starts hidden. + assert_eq!(ocg_names(&doc), ["0", "Walls", "Doors"]); + assert!(doc.ocgs.iter().all(|g| !g.off)); + // One sheet: the viewer opens with its layer panel. + assert_eq!(doc.page_mode.as_deref(), Some("UseOC")); // The page maps its marks to the groups, and the marked sections are balanced. - // Marks are named after the layer's place in the layer table (0, Defpoints, Walls, …). - let page = &pages(&objs)[0]; - for i in [0, 2, 5] { - let id = reference(page, &format!("/L{i}")); - assert!(ocgs.contains(&id)); + let page = &doc.pages[0]; + let groups: Vec<&str> = page.properties.iter().map(|(_, g)| g.as_str()).collect(); + for n in ["0", "Walls", "Doors"] { + assert!(groups.contains(&n), "{n} in {groups:?}"); } - let c = &contents(&objs)[0]; - assert_eq!(c.matches(" BDC\n").count(), c.matches("EMC\n").count()); - assert!(c.contains("/OC /L2 BDC\n1 0 0 RG"), "the red wall line is on Walls: {c}"); - let (q, bdc, emc) = (c.find("re W n").unwrap(), c.find("BDC").unwrap(), c.rfind("EMC").unwrap()); - assert!(q < bdc && emc < c.rfind("Q\n").unwrap(), "marked content inside the clip's q/Q"); + let c = &page.content; + assert_eq!(pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC")); + for m in marks(c) { + assert!(page.properties.iter().any(|(k, _)| *k == m), "mark {m} is a page property"); + } + // The red wall line is on Walls. + let walls = page.properties.iter().find(|(_, g)| g == "Walls").map(|(k, _)| k.clone()).unwrap(); + let ops = pdf_read::ops(c); + let at = ops.iter().position(|o| o.op == "BDC" && o.names.get(1) == Some(&walls)).unwrap(); + let next = ops[at..].iter().find(|o| o.op == "RG").unwrap(); + assert_eq!(next.nums, [1.0, 0.0, 0.0], "{c}"); + // Marked content inside the clip's q/Q. + let first_bdc = ops.iter().position(|o| o.op == "BDC").unwrap(); + let last_emc = ops.iter().rposition(|o| o.op == "EMC").unwrap(); + let clip = ops.iter().position(|o| o.op == "W").unwrap(); + let last_q = ops.iter().rposition(|o| o.op == "Q").unwrap(); + assert!(clip < first_bdc && last_emc < last_q, "marked content inside the clip's q/Q"); } #[test] fn layers_that_are_off_are_included_hidden_on_request() { let d = layered(); - let objs = objects(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap()); - let names = ocg_names(&objs); - assert!(names.contains(&"(Notes)".to_string()) && !names.contains(&"(Hidden)".to_string()), "{names:?}"); - let notes = objs.iter().find(|(_, b)| b.contains("/Name (Notes)")).unwrap().0; - let cat = catalog(&objs); - assert!(cat.contains(&format!("/OFF [{notes} 0 R]")), "{cat}"); - // Off: neither the layer nor its objects; without layers, no optional content at all. - let flat = objects(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap()); - assert!(ocg_names(&flat).is_empty() && !catalog(&flat).contains("/OCProperties")); - assert!(!contents(&flat)[0].contains("BDC")); + let doc = read(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap()); + let names = ocg_names(&doc); + assert!(names.contains(&"Notes".to_string()) && !names.contains(&"Hidden".to_string()), "{names:?}"); + let off: Vec<&str> = doc.ocgs.iter().filter(|g| g.off).map(|g| g.name.as_str()).collect(); + assert_eq!(off, ["Notes"]); + // Without layers, no optional content at all. + let flat = read(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap()); + assert!(flat.ocgs.is_empty() && flat.page_mode.is_none()); + assert_eq!(pdf_read::count(flat.content(), "BDC"), 0); +} + +#[test] +fn layers_that_are_off_are_plotted_only_on_request() { + let d = layered(); + let strokes = |o: serde_json::Value| { + let doc = read(&plot(&d, &Space::Model, &raw(o)).unwrap()); + pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m").count() + }; + // By default Notes (off) and Hidden (frozen) aren't drawn: the four lines less those two, + // plus the door. + let plain = strokes(json!({})); + assert_eq!(plain, 3, "lines on 0 and Walls, and the door leaf"); + // `offLayers` adds Notes (on a hidden PDF layer), never the frozen layer; it needs PDF layers. + assert_eq!(strokes(json!({"offLayers": true})), 4); + assert_eq!(strokes(json!({"layers": false, "offLayers": true})), plain); } #[test] @@ -169,43 +152,50 @@ fn viewport_contents_keep_their_layers() { }; d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap(); add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0)); - let objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap()); - let names = ocg_names(&objs); + let names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap())); // The viewport's frozen layer is left out; the model layers seen through it are kept. - for n in ["(Viewports)", "(0)", "(Doors)", "(Title)"] { + for n in ["Viewports", "0", "Doors", "Title"] { assert!(names.contains(&n.to_string()), "{n} in {names:?}"); } - assert!(!names.contains(&"(Walls)".to_string())); + assert!(!names.contains(&"Walls".to_string())); } #[test] fn several_sheets_in_one_file_with_page_labels() { let mut d = layered(); add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0)); - let bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap(); + let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap()); // "all": model (it has objects), then every layout in tab order. - let all = objects(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap()); - assert!(catalog(&all).contains("[0 << /P (Model) >> 1 << /P (Layout1) >> 2 << /P (Layout2) >>]"), "{}", catalog(&all)); - let objs = objects(&bytes); - let tree = objs.iter().find(|(_, b)| b.starts_with("<< /Type /Pages")).unwrap().1.clone(); - assert!(tree.contains("/Count 2"), "{tree}"); - assert_eq!(pages(&objs).len(), 2); - let cat = catalog(&objs); - assert!(cat.contains("/PageLabels << /Nums [0 << /P (Model) >> 1 << /P (Layout1) >>] >>"), "{cat}"); - // One OCG per layer for the whole file, shared by the pages. - let names = ocg_names(&objs); - assert_eq!(names.iter().filter(|n| *n == "(Walls)").count(), 1); - let walls = objs.iter().find(|(_, b)| b.contains("/Name (Walls)")).unwrap().0; - for p in pages(&objs) { - assert!(p.contains(&format!(" {walls} 0 R")), "{p}"); - } + let all = read(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap()); + assert_eq!(all.labels, ["Model", "Layout1", "Layout2"]); + assert_eq!(doc.pages.len(), 2); + assert_eq!(doc.labels, ["Model", "Layout1"]); // Each page has its own sheet: model on A4 landscape, the layout on its page setup. - let c = contents(&objs); - assert!(c[0].contains("1 0 0 RG") && c[1].contains("1 0 0 RG")); + for p in &doc.pages { + assert!(pdf_read::ops(&p.content).iter().any(|o| o.op == "RG" && o.nums == [1.0, 0.0, 0.0]), "the red wall line: {}", p.content); + } + let size = |p: &pdf_read::Page| (p.media[2] - p.media[0], p.media[3] - p.media[1]); + assert!((size(&doc.pages[0]).0 - 841.89).abs() < 0.01 && (size(&doc.pages[0]).1 - 595.276).abs() < 0.01); // The library call does the same. let o = PdfOptions { compress: false, ..PdfOptions::default() }; - let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap()); - assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>")); + let doc2 = read(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap()); + assert_eq!(doc2.labels, ["Layout1", "Model"]); + // Title: the first page's label unless given. + assert_eq!(doc2.title.as_deref(), Some("Layout1")); + assert_eq!((doc2.creator.as_deref(), doc2.producer.as_deref()), (Some("CADCraft"), Some("CADCraft"))); +} + +#[test] +fn several_sheets_share_one_pdf_layer_per_drawing_layer() { + let mut d = layered(); + add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0)); + let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap()); + assert_eq!(ocg_names(&doc).iter().filter(|n| *n == "Walls").count(), 1); + for p in &doc.pages { + assert!(p.properties.iter().any(|(_, g)| g == "Walls")); + } + // Several sheets: no layer panel forced open. + assert!(doc.page_mode.is_none()); } #[test] @@ -230,28 +220,51 @@ fn hyperlinks_become_link_annotations() { {"handle": h.hex(), "url": "https://example.com/ü"}, {"url": "https://example.com/no-handle"}, ]); - let objs = objects(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap()); - let annots: Vec<&String> = objs.iter().map(|(_, b)| b).filter(|b| b.contains("/Subtype /Link")).collect(); - assert_eq!(annots.len(), 2, "{annots:?}"); - assert!(annots[0].contains("/A << /S /URI /URI (https://example.com/a\\(1\\)) >>"), "{}", annots[0]); - assert!(annots[1].contains("(https://example.com/%C3%BC)"), "{}", annots[1]); - let page = &pages(&objs)[0]; - assert!(page.contains("/Annots [")); + let doc = read(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap()); + let annots = &doc.pages[0].links; + assert_eq!(annots.len(), 2); + assert_eq!(annots[0].uri, "https://example.com/a(1)"); + assert_eq!(annots[1].uri, "https://example.com/%C3%BC"); // The rectangle is inside the A4 landscape sheet and wide like the line. - let r = annots[0].split("/Rect [").nth(1).unwrap().split(']').next().unwrap(); - let v: Vec = r.split_whitespace().map(|x| x.parse().unwrap()).collect(); + let v = annots[0].rect; assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}"); } -#[test] -fn unusual_layer_names_and_titles_are_escaped() { +fn unusual_names() -> Drawing { let mut d = Drawing::new_metric(); for name in ["a(b)\\c", "Wände", "日本"] { d.layers.push(Layer { name: name.into(), ..Layer::default() }); add_line(&mut d, &Space::Model, name, (0.0, 0.0), (10.0, 10.0)); } - let objs = objects(&plot(&d, &Space::Model, &raw(json!({"title": "Plan ü"}))).unwrap()); - let names = ocg_names(&objs); - assert_eq!(names, ["(a\\(b\\)\\\\c)", "", ""]); - assert!(objs.iter().any(|(_, b)| b.contains("/Title "))); + d +} + +#[test] +fn unusual_layer_names_are_kept() { + let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({}))).unwrap()); + assert_eq!(ocg_names(&doc), ["a(b)\\c", "Wände", "日本"]); +} + +#[test] +fn unusual_titles_are_kept() { + let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({"title": "Plan ü (1)\\"}))).unwrap()); + assert_eq!(doc.title.as_deref(), Some("Plan ü (1)\\")); +} + +#[test] +fn layers_past_the_cap_print_without_a_pdf_layer() { + // One more drawing layer than a file has PDF layers (4096). + let mut d = Drawing::new_metric(); + for i in 0..4097 { + let name = format!("L{i}"); + d.layers.push(Layer { name: name.clone(), ..Layer::default() }); + add_line(&mut d, &Space::Model, &name, (0.0, f64::from(i)), (10.0, f64::from(i))); + } + let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap()); + assert_eq!(doc.ocgs.len(), 4096); + assert!(!ocg_names(&doc).contains(&"L4096".to_string())); + // Every line is still drawn; only the last is outside marked content. + let c = doc.content(); + assert_eq!(pdf_read::drawn(c).iter().filter(|o| o.op == "m").count(), 4097); + assert_eq!((pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC")), (4096, 4096)); } diff --git a/crates/io/tests/it/plot_layout_scale.rs b/crates/io/tests/it/plot_layout_scale.rs index 8512d7abc..3c18b7e83 100644 --- a/crates/io/tests/it/plot_layout_scale.rs +++ b/crates/io/tests/it/plot_layout_scale.rs @@ -14,9 +14,10 @@ fn plotted_mm(opts: Value) -> f64 { o.extend(e.clone()); } let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap(); - let text = String::from_utf8_lossy(&bytes).into_owned(); - let xs: Vec = text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split(' ').next()?.parse().ok()).collect(); - assert_eq!(xs.len(), 2, "{text}"); + let doc = crate::pdf_read::read(&bytes); + let xs: Vec = + crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect(); + assert_eq!(xs.len(), 2, "{}", doc.content()); (xs[1] - xs[0]).abs() * 25.4 / 72.0 } diff --git a/crates/io/tests/it/plot_styles.rs b/crates/io/tests/it/plot_styles.rs index 4e6a3f6c4..679c167b9 100644 --- a/crates/io/tests/it/plot_styles.rs +++ b/crates/io/tests/it/plot_styles.rs @@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing { d } -/// The uncompressed PDF of Layout1 with extra plot options. +/// The page content of Layout1's PDF with extra plot options. fn pdf_text(d: &Drawing, extra: serde_json::Value) -> String { let mut opts = json!({"compress": false}); if let (Some(o), Some(e)) = (opts.as_object_mut(), extra.as_object()) { o.extend(e.clone()); } let bytes = cadcraft_io::plot(d, &Space::Paper("Layout1".into()), &opts).unwrap(); - String::from_utf8_lossy(&bytes).into_owned() + crate::pdf_read::read(&bytes).content().to_string() } #[test] diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs index 4863ee628..c238b2013 100644 --- a/crates/io/tests/pdf_text.rs +++ b/crates/io/tests/pdf_text.rs @@ -10,6 +10,11 @@ use cadcraft_geom::{Vec2, Vec3}; use cadcraft_io::plot; use serde_json::json; +#[path = "support/pdf_read.rs"] +mod pdf_read; + +use pdf_read::Doc; + // ---------- a minimal TrueType font: .notdef, "A" (triangle), "B" (square) ---------- fn be16(v: &mut Vec, x: u16) { @@ -46,8 +51,9 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec { g } -/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`. -fn test_font(fs_type: u16) -> Vec { +/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`. `hmtx` holds +/// `metrics` of the three metrics `hhea` declares. +fn test_font(fs_type: u16, metrics: usize) -> 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(); @@ -82,7 +88,7 @@ fn test_font(fs_type: u16) -> Vec { be16(&mut maxp, v); } let mut hmtx = Vec::new(); - for _ in 0..3 { + for _ in 0..metrics { be16(&mut hmtx, 600); be16(&mut hmtx, 50); } @@ -142,12 +148,14 @@ fn test_font(fs_type: u16) -> Vec { out } -/// Register the test fonts once: an embeddable one and one with a restricted licence. +/// Register the test fonts once: an embeddable one, one with a restricted licence, and one +/// the PDF writer accepts but can't subset (its `hmtx` is short). 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)); + cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0, 3)); + cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002, 3)); + cadcraft_fonts::ttf::register("cadcraft-pdf-short-hmtx.ttf", test_font(0, 1)); }); } @@ -178,77 +186,72 @@ fn drawing() -> Drawing { let mut d = Drawing::new_metric(); style(&mut d, "Real", "cadcraft-pdf-test.ttf"); style(&mut d, "Locked", "cadcraft-pdf-restricted.ttf"); + style(&mut d, "Short", "cadcraft-pdf-short-hmtx.ttf"); d } -/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`). -fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String { +/// The uncompressed PDF, read back. +fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc { 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() + pdf_read::read(&plot(d, space, &o).unwrap()) } -/// 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 glyph matrices (`a b c d e f`: em axes and origin on the page) of the text, in order. +fn matrices(doc: &Doc) -> Vec<[f64; 6]> { + pdf_read::text_matrices(doc.content()) } -/// 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]] - }) +/// The glyph codes shown, in order (two bytes each). +fn codes(doc: &Doc) -> Vec { + pdf_read::shown(&doc.pages[0].content_bytes) + .iter() + .flat_map(|s| s.chunks(2).map(|c| u32::from(c[0]) << 8 | u32::from(*c.get(1).unwrap_or(&0))).collect::>()) .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() +/// Filled paths drawn (glyph outlines are fills). +fn fills(doc: &Doc) -> usize { + pdf_read::count(doc.content(), "f") +} + +/// Embedded font files. +fn font_files(doc: &Doc) -> usize { + doc.pages.iter().flat_map(|p| &p.fonts).filter(|f| f.font_file.is_some()).count() } #[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!({})); + let doc = pdf(&d, &Space::Model, json!({})); + let page = &doc.pages[0]; // 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()); + assert_eq!(page.fonts.len(), 1); + let f = &page.fonts[0]; + assert_eq!((f.subtype.as_str(), f.encoding.as_deref(), f.descendant.as_deref()), ("Type0", Some("Identity-H"), Some("CIDFontType2"))); + assert!(f.cid_to_gid.as_deref() == Some("Identity") && f.font_file.is_some() && f.to_unicode.is_some()); + let base = f.base_font.as_bytes(); + assert!(base.len() > 7 && base[6] == b'+' && base[..6].iter().all(u8::is_ascii_uppercase), "{}", f.base_font); + // Four glyphs written as text: A B B A. + let c = codes(&doc); + assert_eq!(c.len(), 4, "{c:?}"); + assert!(c[0] == c[3] && c[1] == c[2] && c[0] != c[1], "{c:?}"); + let map = pdf_read::to_unicode_map(f.to_unicode.as_deref().unwrap()); + assert!(map.contains(&(c[0], "A".into())) && map.contains(&(c[1], "B".into())), "ToUnicode maps the glyphs to A and B: {map:?}"); + for code in [c[0], c[1]] { + assert!(f.widths.contains(&(code, 600.0)), "width of {code}: {:?}", f.widths); + } + // Text objects are balanced, at font size 1. + assert_eq!(pdf_read::count(&page.content, "BT"), pdf_read::count(&page.content, "ET")); + assert!(pdf_read::ops(&page.content).iter().filter(|o| o.op == "Tf").all(|o| o.nums == [1.0])); // No filled outlines left for them. - assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}"); + assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content); // 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 m = matrices(&doc); + 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), "{m:?}"); 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"); @@ -258,21 +261,12 @@ fn truetype_text_is_embedded_as_text() { 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 doc = pdf(&d, &Space::Model, json!({})); + let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file"); 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); + // Glyph A kept, B (unused) left out: the subset is smaller than the font. + assert!(file.len() < test_font(0, 3).len()); } #[test] @@ -283,7 +277,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { 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); + assert_eq!(m.len(), 3, "{m:?}"); // 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]); @@ -297,21 +291,29 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { assert!(em_mm > 0.0); } +#[test] +fn a_font_the_writer_cannot_subset_plots_as_outlines() { + let mut d = drawing(); + text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + let p = pdf(&d, &Space::Model, json!({})); + assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content()); +} + #[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"); + assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "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"); + assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "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")); + assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0); } #[test] @@ -337,14 +339,26 @@ fn text_seen_through_a_viewport_and_on_layers() { 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")); + assert_eq!(pdf_read::count(p.content(), "Tj"), 4, "{}", p.content()); + assert!(fills(&p) > 0, "the cut glyph is an outline"); + // Text seen through the viewport sits in its layer's marked content. + let ops = pdf_read::ops(p.content()); + let first_bt = ops.iter().position(|o| o.op == "BT").unwrap(); + let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap(); + assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0))); +} + +#[test] +fn text_sits_in_its_layers_marked_content() { + 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!({})); + let ops = pdf_read::ops(p.content()); + let first_bt = ops.iter().position(|o| o.op == "BT").unwrap(); + let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap(); + assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0))); } #[test] diff --git a/crates/io/tests/support/pdf_read.rs b/crates/io/tests/support/pdf_read.rs new file mode 100644 index 000000000..992544089 --- /dev/null +++ b/crates/io/tests/support/pdf_read.rs @@ -0,0 +1,518 @@ +//! Reads a plotted PDF back for tests, with craft-pdf's reader: pages, media boxes, decoded +//! content, transparency graphics states, link annotations, fonts, page labels and document +//! information. +#![allow(dead_code)] + +use std::ops::Deref; + +use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfString}; + +/// What a reader sees in a PDF. +pub struct Doc { + pub pages: Vec, + /// Page labels, one per page (empty when the file has none). + pub labels: Vec, + pub title: Option, + pub creator: Option, + pub producer: Option, + /// Optional content groups (PDF layers) in `/OCProperties` order. + pub ocgs: Vec, + /// The catalog's `/PageMode`. + pub page_mode: Option, +} + +pub struct Ocg { + pub name: String, + /// Listed in the default configuration's `/OFF` array (starts hidden). + pub off: bool, +} + +pub struct Page { + /// `[x0 y0 x1 y1]` in points. + pub media: [f64; 4], + /// The decoded content stream(s), as text (binary string bytes replaced). + pub content: String, + /// The decoded content stream(s) as bytes. + pub content_bytes: Vec, + /// Whether a content stream is compressed (has a filter). + pub compressed: bool, + /// Stroke and fill alpha (`/CA`, `/ca`) of each graphics state in the page's resources. + pub alphas: Vec<(f64, f64)>, + pub links: Vec, + pub fonts: Vec, + /// The page's marked-content properties: resource name and the name of the optional + /// content group it refers to. + pub properties: Vec<(String, String)>, +} + +pub struct Link { + /// `[x0 y0 x1 y1]` in points. + pub rect: [f64; 4], + pub uri: String, +} + +pub struct Font { + pub subtype: String, + pub base_font: String, + pub encoding: Option, + /// The descendant font's subtype (Type0 fonts). + pub descendant: Option, + /// The descendant font's `/CIDToGIDMap` name. + pub cid_to_gid: Option, + /// Glyph widths (`/W` of the descendant font): code and width. + pub widths: Vec<(u32, f64)>, + /// The decoded `ToUnicode` CMap. + pub to_unicode: Option, + /// The embedded TrueType file (`FontFile2`). + pub font_file: Option>, +} + +/// A PDF text string: UTF-16BE with a byte order mark, else one byte per character. +pub fn text_string(b: &[u8]) -> String { + match b.strip_prefix(&[0xFE, 0xFF]) { + Some(u) => String::from_utf16_lossy(&u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect::>()), + None => b.iter().map(|c| char::from(*c)).collect(), + } +} + +fn name(d: &Dict<'_>, key: &[u8]) -> Option { + d.get::>(key).map(|n| n.as_str().to_string()) +} + +fn rect(a: &Array<'_>) -> [f64; 4] { + let v: Vec = a.iter::().collect(); + assert_eq!(v.len(), 4, "a rectangle: {v:?}"); + [v[0], v[1], v[2], v[3]] +} + +fn decoded(s: &Stream<'_>) -> Vec { + s.decoded().expect("a stream that decodes").to_vec() +} + +fn font(d: &Dict<'_>) -> Font { + let descendant = d.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()); + let font_file = + descendant.as_ref().unwrap_or(d).get::>(b"FontDescriptor").and_then(|fd| fd.get::>(b"FontFile2")).map(|s| decoded(&s)); + let mut widths = Vec::new(); + if let Some(w) = descendant.as_ref().and_then(|c| c.get::>(b"W")) { + let mut it = w.flex_iter(); + while let Some(first) = it.next::() { + if let Some(list) = it.next::>() { + widths.extend(list.iter::().enumerate().map(|(i, v)| (first + i as u32, v))); + } else { + let (last, v) = (it.next::().expect("a /W range end"), it.next::().expect("a /W range width")); + widths.extend((first..=last).map(|c| (c, v))); + } + } + } + Font { + encoding: name(d, b"Encoding"), + cid_to_gid: descendant.as_ref().and_then(|c| name(c, b"CIDToGIDMap")), + widths, + subtype: name(d, b"Subtype").unwrap_or_default(), + base_font: name(d, b"BaseFont").unwrap_or_default(), + descendant: descendant.as_ref().and_then(|c| name(c, b"Subtype")), + to_unicode: d.get::>(b"ToUnicode").map(|s| String::from_utf8_lossy(&decoded(&s)).into_owned()), + font_file, + } +} + +/// Read a PDF; panics when it doesn't parse. +pub fn read(bytes: &[u8]) -> Doc { + assert!(bytes.starts_with(b"%PDF-"), "a PDF header"); + let pdf = craft_pdf::open_slice(bytes, None).expect("the PDF opens"); + let mut pages = Vec::new(); + for page in pdf.pages().iter() { + let m = page.media_box(); + let raw = page.raw(); + let resources = raw.get::>(b"Resources"); + let dicts = |key: &[u8]| -> Vec> { + let Some(d) = resources.as_ref().and_then(|r| r.get::>(key)) else { return Vec::new() }; + let keys: Vec> = d.keys().map(|k| k.deref().to_vec()).collect(); + keys.iter().filter_map(|k| d.get::>(k.as_slice())).collect() + }; + let alphas = dicts(b"ExtGState").iter().map(|g| (g.get::(b"CA").unwrap_or(1.0), g.get::(b"ca").unwrap_or(1.0))).collect(); + let fonts = dicts(b"Font").iter().map(font).collect(); + let properties = match resources.as_ref().and_then(|r| r.get::>(b"Properties")) { + Some(d) => d + .keys() + .filter_map(|k| { + let ocg = d.get::>(k.deref())?; + Some((k.as_str().to_string(), ocg.get::>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default())) + }) + .collect(), + None => Vec::new(), + }; + let links = raw + .get::>(b"Annots") + .map(|a| a.iter::>().collect::>()) + .unwrap_or_default() + .iter() + .filter(|a| name(a, b"Subtype").as_deref() == Some("Link")) + .map(|a| Link { + rect: rect(&a.get::>(b"Rect").expect("a link rectangle")), + uri: a + .get::>(b"A") + .and_then(|act| act.get::>(b"URI")) + .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) + .unwrap_or_default(), + }) + .collect(); + let bytes = page.page_stream().expect("a decodable page content stream"); + // The writer flips its y-down surface back, so the plot area is drawn in PDF points. + let content = String::from_utf8_lossy(bytes).into_owned(); + if let Some(ctm) = clip_ctm(&content) { + assert!(ctm.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6), "the plot area is drawn under {ctm:?}, not in page space"); + } + pages.push(Page { + media: [m.x0, m.y0, m.x1, m.y1], + content, + content_bytes: bytes.to_vec(), + compressed: raw + .get::>(b"Contents") + .map(|c| vec![c]) + .or_else(|| raw.get::>(b"Contents").map(|a| a.iter::>().collect())) + .unwrap_or_default() + .iter() + .any(|c| c.dict().contains_key(b"Filter")), + alphas, + links, + fonts, + properties, + }); + } + let catalog = pdf.xref().get::>(pdf.xref().root_id()).expect("a catalog"); + let mut starts: Vec<(usize, String)> = Vec::new(); + if let Some(nums) = catalog.get::>(b"PageLabels").and_then(|l| l.get::>(b"Nums")) { + let mut it = nums.flex_iter(); + while let Some(i) = it.next::() { + let d = it.next::>().expect("a page label dictionary"); + starts.push((i as usize, d.get::>(b"P").map(|s| text_string(s.as_bytes())).unwrap_or_default())); + } + } + let labels = if starts.is_empty() { + Vec::new() + } else { + (0..pages.len()).map(|i| starts.iter().rev().find(|(s, _)| *s <= i).map(|(_, p)| p.clone()).unwrap_or_default()).collect() + }; + let mut ocgs = Vec::new(); + if let Some(oc) = catalog.get::>(b"OCProperties") { + let off: Vec<_> = oc + .get::>(b"D") + .and_then(|d| d.get::>(b"OFF")) + .map(|a| a.iter::>().filter_map(|g| g.obj_id()).collect()) + .unwrap_or_default(); + for g in oc.get::>(b"OCGs").map(|a| a.iter::>().collect::>()).unwrap_or_default() { + ocgs.push(Ocg { + name: g.get::>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default(), + off: g.obj_id().is_some_and(|id| off.contains(&id)), + }); + } + } + let page_mode = name(&catalog, b"PageMode"); + let md = pdf.metadata(); + let s = |v: &Option>| v.as_deref().map(text_string); + Doc { labels, title: s(&md.title), creator: s(&md.creator), producer: s(&md.producer), pages, ocgs, page_mode } +} + +impl Doc { + /// The first page's content. + pub fn content(&self) -> &str { + &self.pages.first().expect("a page").content + } + pub fn media(&self) -> (f64, f64) { + let m = self.pages.first().expect("a page").media; + (m[2] - m[0], m[3] - m[1]) + } +} + +/// One content operator with its numeric operands. +#[derive(Debug, Clone, PartialEq)] +pub struct Op { + pub op: String, + pub nums: Vec, + /// Name operands, without the slash. + pub names: Vec, +} + +/// The operators of content text in order, each with its numeric operands (other operands +/// are skipped). +pub fn ops(content: &str) -> Vec { + let b: Vec = content.chars().collect(); + let mut out = Vec::new(); + let mut nums = Vec::new(); + let mut names = Vec::new(); + let mut i = 0; + let delim = |c: char| c.is_whitespace() || "()<>[]{}/%".contains(c); + while i < b.len() { + let c = b[i]; + if c.is_whitespace() || c == '[' || c == ']' { + i += 1; + } else if c == '%' { + while i < b.len() && b[i] != '\n' { + i += 1; + } + } else if c == '(' { + let mut depth = 0; + while i < b.len() { + match b[i] { + '\\' => i += 1, + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + i += 1; + } + i += 1; + } else if c == '<' && b.get(i + 1) == Some(&'<') { + // An inline dictionary (marked-content properties): skip to its end. + let mut depth = 0; + while i < b.len() { + if b[i] == '<' && b.get(i + 1) == Some(&'<') { + depth += 1; + i += 1; + } else if b[i] == '>' && b.get(i + 1) == Some(&'>') { + depth -= 1; + i += 1; + if depth == 0 { + i += 1; + break; + } + } + i += 1; + } + } else if c == '<' { + while i < b.len() && b[i] != '>' { + i += 1; + } + i += 1; + } else if c == '/' { + i += 1; + let start = i; + while i < b.len() && !delim(b[i]) { + i += 1; + } + names.push(b[start..i].iter().collect()); + } else { + let start = i; + while i < b.len() && !delim(b[i]) { + i += 1; + } + let word: String = b[start..i.max(start + 1)].iter().collect(); + i = i.max(start + 1); + match word.parse::() { + Ok(v) => nums.push(v), + Err(_) => out.push(Op { op: word, nums: std::mem::take(&mut nums), names: std::mem::take(&mut names) }), + } + } + } + out +} + +/// How many times operator `op` occurs. +pub fn count(content: &str, op: &str) -> usize { + ops(content).iter().filter(|o| o.op == op).count() +} + +const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// Apply matrix `m` to the point `(x, y)`. +fn apply(m: [f64; 6], x: f64, y: f64) -> (f64, f64) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +/// The operators of content text with path coordinates (`m`, `l`, `c`, `v`, `y`, `re`) mapped +/// through the current transformation matrix, so they are in page space. A `re` becomes the +/// rectangle between its mapped corners, which holds for the axis-aligned matrices plots use. +pub fn page_ops(content: &str) -> Vec { + let mut stack = vec![IDENTITY]; + let mut out = ops(content); + for o in &mut out { + let ctm = *stack.last().expect("a graphics state"); + match o.op.as_str() { + "q" => stack.push(ctm), + "Q" => { + stack.pop(); + assert!(!stack.is_empty(), "unbalanced Q"); + } + "cm" if o.nums.len() == 6 => { + let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]]; + *stack.last_mut().expect("a graphics state") = mul(m, ctm); + } + "m" | "l" | "c" | "v" | "y" => { + for [x, y] in o.nums.as_chunks_mut::<2>().0 { + (*x, *y) = apply(ctm, *x, *y); + } + } + "re" if o.nums.len() == 4 => { + let (x0, y0) = apply(ctm, o.nums[0], o.nums[1]); + let (x1, y1) = apply(ctm, o.nums[0] + o.nums[2], o.nums[1] + o.nums[3]); + o.nums = vec![x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]; + } + _ => {} + } + } + out +} + +/// The current transformation matrix at the first clip (`W`): what the plot area is drawn under. +pub fn clip_ctm(content: &str) -> Option<[f64; 6]> { + let mut stack = vec![IDENTITY]; + for o in ops(content) { + let ctm = *stack.last().expect("a graphics state"); + match o.op.as_str() { + "W" => return Some(ctm), + "q" => stack.push(ctm), + "Q" => { + stack.pop(); + } + "cm" if o.nums.len() == 6 => { + let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]]; + *stack.last_mut().expect("a graphics state") = mul(m, ctm); + } + _ => {} + } + } + None +} + +/// The clip rectangle in page space: the bounds of the path closed by the first `W n`. +pub fn clip_rect(content: &str) -> Option<[f64; 4]> { + let all = page_ops(content); + let end = all.iter().position(|o| o.op == "W")?; + let start = all[..end].iter().rposition(|o| !matches!(o.op.as_str(), "m" | "l" | "h" | "re")).map_or(0, |i| i + 1); + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for o in &all[start..end] { + let pts: Vec<(f64, f64)> = match o.op.as_str() { + "re" => vec![(o.nums[0], o.nums[1]), (o.nums[0] + o.nums[2], o.nums[1] + o.nums[3])], + "m" | "l" => vec![(o.nums[0], o.nums[1])], + _ => vec![], + }; + for (x, y) in pts { + b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + Some(b) +} + +/// The operators after the clip (the marks drawn inside the plot area), with path coordinates +/// in page space. +pub fn drawn(content: &str) -> Vec { + let all = page_ops(content); + let after = all.iter().position(|o| o.op == "W").map_or(0, |i| i + 1); + all.into_iter().skip(after).collect() +} + +/// `a × b` for PDF matrices `[a b c d e f]` (apply `a`, then `b`). +pub fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] { + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +/// The glyph-space-to-page matrix of each text-showing operator (`Tj`/`TJ`), in order: the text +/// matrix scaled by the font size, under the current transformation matrix. +pub fn text_matrices(content: &str) -> Vec<[f64; 6]> { + let mut stack = vec![IDENTITY]; + let (mut tm, mut size) = (IDENTITY, 1.0); + let mut out = Vec::new(); + for o in ops(content) { + let ctm = *stack.last().expect("a graphics state"); + match o.op.as_str() { + "q" => stack.push(ctm), + "Q" => { + stack.pop(); + } + "cm" if o.nums.len() == 6 => { + let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]]; + *stack.last_mut().expect("a graphics state") = mul(m, ctm); + } + "BT" => tm = IDENTITY, + "Tm" if o.nums.len() == 6 => tm = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]], + "Tf" => size = o.nums.first().copied().unwrap_or(1.0), + "Tj" | "TJ" => out.push(mul(mul([size, 0.0, 0.0, size, 0.0, 0.0], tm), ctm)), + _ => {} + } + } + out +} + +/// The string operands of text-showing operators (`Tj`), as bytes, in order. +pub fn shown(content: &[u8]) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while i < content.len() { + if content[i] == b'(' { + let mut s = Vec::new(); + let mut depth = 1; + i += 1; + while i < content.len() { + match content[i] { + b'\\' => { + i += 1; + let c = content.get(i).copied().unwrap_or(b'\\'); + if c.is_ascii_digit() { + let digits: Vec = content[i..].iter().take(3).take_while(|d| (b'0'..=b'7').contains(d)).copied().collect(); + s.push(u8::from_str_radix(std::str::from_utf8(&digits).unwrap_or("0"), 8).unwrap_or(0)); + i += digits.len() - 1; + } else { + s.push(match c { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + other => other, + }); + } + } + b'(' => { + depth += 1; + s.push(b'('); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + s.push(b')'); + } + c => s.push(c), + } + i += 1; + } + let rest = &content[(i + 1).min(content.len())..]; + let next: Vec = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take(2).copied().collect(); + if next == b"Tj" { + out.push(s); + } + } + i += 1; + } + out +} + +/// The `bfchar` entries of a `ToUnicode` CMap: code and text. +pub fn to_unicode_map(cmap: &str) -> Vec<(u32, String)> { + let mut out = Vec::new(); + for block in cmap.split("beginbfchar").skip(1) { + let body = block.split("endbfchar").next().unwrap_or(""); + let hex: Vec<&str> = body.split(['<', '>']).map(str::trim).filter(|t| !t.is_empty()).collect(); + for pair in hex.chunks(2) { + let [code, text] = pair else { continue }; + let units: Vec = (0..text.len() / 4).filter_map(|i| u16::from_str_radix(&text[i * 4..i * 4 + 4], 16).ok()).collect(); + if let Ok(c) = u32::from_str_radix(code, 16) { + out.push((c, String::from_utf16_lossy(&units))); + } + } + } + out +}