Compare commits

..
Author SHA1 Message Date
Dylan M. TaylorandClaude Sonnet 5.5 920f487637 refactor: replace wildcard imports with explicit ones (S2208, batch_09)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:52:48 +00:00
18 changed files with 1106 additions and 1513 deletions
Generated
+3 -224
View File
@@ -434,12 +434,6 @@ 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"
@@ -680,7 +674,6 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -875,12 +868,6 @@ 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"
@@ -961,16 +948,6 @@ 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"
@@ -1334,7 +1311,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
"font-types 0.12.7",
"font-types",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1450,15 +1427,6 @@ 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"
@@ -1471,15 +1439,6 @@ 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"
@@ -1623,16 +1582,6 @@ 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"
@@ -1826,39 +1775,6 @@ 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"
@@ -2016,22 +1932,6 @@ 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"
@@ -2192,32 +2092,6 @@ 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"
@@ -2953,18 +2827,6 @@ 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"
@@ -3291,16 +3153,6 @@ 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"
@@ -3308,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types 0.12.7",
"font-types",
"once_cell",
]
@@ -3320,7 +3172,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"font-types 0.12.7",
"font-types",
"once_cell",
"yoke",
]
@@ -3609,16 +3461,6 @@ 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"
@@ -3762,29 +3604,6 @@ 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"
@@ -3939,12 +3758,6 @@ 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"
@@ -4081,21 +3894,6 @@ 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"
@@ -4891,19 +4689,6 @@ 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"
@@ -4973,12 +4758,6 @@ 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"
-8
View File
@@ -26,9 +26,6 @@ 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"
@@ -61,11 +58,6 @@ 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"
+2 -1
View File
@@ -10,7 +10,8 @@ use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::super::qselect::wildcard;
use super::super::*;
use super::super::{CommandSpec, bad, point_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step, View};
/// Most names one Delete or list takes.
+2 -1
View File
@@ -15,7 +15,8 @@ use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::super::qselect::wildcard;
use super::super::*;
use super::super::{CommandSpec, bad, find_command, str_param};
use crate::{Accept, DocState, EngineError, Input, Interactive, ModelTile, ModelTiles, Prompt, Result, Session, Step, View};
/// Most tiles on screen at once.
+286 -8
View File
@@ -1,13 +1,25 @@
//! 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.
//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document
//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs
//! become empty), so text written with the original ids shows the same glyphs.
//!
//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted
//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as
//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset
//! here either.
use skrifa::FontRef;
use std::collections::BTreeSet;
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::types::Tag;
use skrifa::{FontRef, MetadataProvider};
use crate::ttf::Face;
/// Largest subset written (bytes).
pub const MAX_SUBSET_BYTES: usize = 32 << 20;
/// Most components followed in one composite glyph, and most glyphs a subset may reach.
const MAX_COMPONENTS: usize = 256;
/// Whether a face may be embedded as a subset.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Embedding {
@@ -23,17 +35,40 @@ 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<'_>> {
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::new(t)).map(|d| d.as_bytes())
f.table_data(tag(t)).map(|d| d.as_bytes())
}
fn u16_at(b: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
}
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
}
/// The embedding permission of a face.
pub fn embedding(face: &Face) -> Embedding {
let Ok(f) = FontRef::from_index(&face.bytes, face.index) else { return Embedding::NotTrueType };
let Some(f) = font(face) else { return Embedding::NotTrueType };
if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() {
return Embedding::NotTrueType;
}
@@ -49,14 +84,257 @@ 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<FontInfo> {
let f = font(face)?;
let m = f.metrics(Size::new(1000.0), LocationRef::default());
let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]);
let name: String = f
.localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
.english_or_first()
.map(|s| s.to_string())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(63)
.collect();
Some(FontInfo {
postscript_name: if name.is_empty() { "Font".into() } else { name },
bbox,
ascent: fin(m.ascent),
descent: fin(m.descent),
cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72),
italic_angle: fin(m.italic_angle),
})
}
/// The advance width of glyph `gid` in units of 1/1000 em.
pub fn advance(face: &Face, gid: u16) -> f64 {
font(face)
.and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid))))
.map(fin)
.unwrap_or(0.0)
}
/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length.
fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option<Vec<usize>> {
let mut v = Vec::with_capacity(n + 1);
for i in 0..=n {
let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 };
v.push(o.min(glyf_len));
}
Some(v)
}
/// The data of glyph `g` (empty when its range is bad).
fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] {
match (offsets.get(g), offsets.get(g + 1)) {
(Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]),
_ => &[],
}
}
/// The glyph ids a composite glyph is built from.
fn components(data: &[u8]) -> Vec<u16> {
let mut out = Vec::new();
// numberOfContours < 0: a composite.
if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) {
return out;
}
let mut pos = 10;
while out.len() < MAX_COMPONENTS {
let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break };
out.push(gid);
pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 };
pos += if flags & 0x0008 != 0 {
2
} else if flags & 0x0040 != 0 {
4
} else if flags & 0x0080 != 0 {
8
} else {
0
};
if flags & 0x0020 == 0 {
break;
}
}
out
}
/// The sum of a table's big-endian 32-bit words (zero-padded).
fn checksum(data: &[u8]) -> u32 {
data.chunks(4).fold(0u32, |sum, c| {
let mut w = [0u8; 4];
for (d, s) in w.iter_mut().zip(c) {
*d = *s;
}
sum.wrapping_add(u32::from_be_bytes(w))
})
}
/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The
/// other glyphs keep their ids but have no outline.
pub fn subset(face: &Face, gids: &BTreeSet<u16>) -> Result<Vec<u8>, SubsetError> {
let allowed = embedding(face);
if allowed != Embedding::Allowed {
return Err(SubsetError::NotAllowed(allowed));
}
let f = font(face).ok_or(SubsetError::Malformed("font"))?;
let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?;
let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?;
let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?;
let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?;
let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?);
let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0;
let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?;
// The glyphs to keep: asked for, .notdef, and composite components (transitively).
let mut keep: BTreeSet<u16> = BTreeSet::new();
let mut todo: Vec<u16> = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect();
while let Some(g) = todo.pop() {
if !keep.insert(g) {
continue;
}
for c in components(glyph_data(glyf, &offsets, usize::from(g))) {
if usize::from(c) < n && !keep.contains(&c) {
todo.push(c);
}
}
}
let mut new_glyf: Vec<u8> = Vec::new();
let mut new_loca: Vec<u8> = Vec::with_capacity((n + 1) * 4);
for g in 0..n {
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) {
new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g));
while !new_glyf.len().is_multiple_of(4) {
new_glyf.push(0);
}
}
if new_glyf.len() > MAX_SUBSET_BYTES {
return Err(SubsetError::TooLarge);
}
}
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
let mut new_head = head.to_vec();
// checkSumAdjustment is set once the file is complete; offsets are long now.
new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]);
new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes());
let mut tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())];
for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] {
if let Some(d) = table(&f, t) {
tables.push((*t, d.to_vec()));
}
}
if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") {
return Err(SubsetError::Malformed("hhea/hmtx"));
}
tables.sort_by_key(|t| t.0);
let out = sfnt(&tables);
if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) }
}
/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the
/// `head` checksum adjustment.
fn sfnt(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let count = tables.len() as u16;
let mut pow = 1u16;
let mut sel = 0u16;
while pow.saturating_mul(2) <= count {
pow = pow.saturating_mul(2);
sel += 1;
}
let range = pow.saturating_mul(16);
let mut out = Vec::new();
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] {
out.extend_from_slice(&v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
let mut head_at = None;
for (t, d) in tables {
out.extend_from_slice(t);
out.extend_from_slice(&checksum(d).to_be_bytes());
out.extend_from_slice(&(offset as u32).to_be_bytes());
out.extend_from_slice(&(d.len() as u32).to_be_bytes());
offset += d.len().div_ceil(4) * 4;
}
for (t, d) in tables {
if t == b"head" {
head_at = Some(out.len());
}
out.extend_from_slice(d);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
if let Some(h) = head_at {
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
if let Some(slot) = out.get_mut(h + 8..h + 12) {
slot.copy_from_slice(&adj.to_be_bytes());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composite_components_are_followed_and_bounded() {
// numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first).
let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]);
g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]);
assert_eq!(components(&g), [5, 7]);
// Truncated and endless (always MORE_COMPONENTS) data stay bounded.
assert_eq!(components(&g[..14]), [5]);
let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
for _ in 0..2000 {
endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]);
}
assert_eq!(components(&endless).len(), MAX_COMPONENTS);
assert!(components(&[0, 1, 0]).is_empty());
}
#[test]
fn junk_fonts_are_not_embedded() {
let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 };
assert_eq!(embedding(&junk), Embedding::NotTrueType);
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), 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);
}
}
+3 -1
View File
@@ -4,7 +4,9 @@
//! runs a few thousand cheap cases; the known-answer part of the suite works at 1e6–1e7
//! coordinates, where double precision starts to show (geometry-parity.md §Tests).
use crate::*;
use crate::{
Arc, Bounds2, Circle, Line, Segment, Vec2, circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite,
};
// ---------- generator ----------
-3
View File
@@ -20,12 +20,9 @@ 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"] }
+379 -357
View File
@@ -1,12 +1,11 @@
//! 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).
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (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, 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.
//! 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.
//!
//! 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
@@ -20,19 +19,6 @@ 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;
@@ -45,13 +31,9 @@ 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).
@@ -82,8 +64,8 @@ pub struct PdfOptions {
pub window: Option<Bounds2>,
/// Drawing layers become PDF layers (optional content groups). Default on.
pub layers: bool,
/// 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.
/// 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.
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.
@@ -374,121 +356,23 @@ 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")));
}
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<IoError> for Failure {
fn from(e: IoError) -> Self {
Failure::Io(e)
}
}
impl From<WriteError> 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}")),
// 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;
}
}
}
/// 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<Vec<u8>, 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<u32, OcgId>,
}
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);
}
}
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::<Result<Vec<_>>>()?;
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))
}
/// One plotted sheet, ready to be written as a page.
@@ -499,8 +383,7 @@ struct SheetPage {
list: cadcraft_render::DisplayList,
map: Map,
clip_pt: Bounds2,
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
/// candidates for PDF layers.
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
layers: Vec<u32>,
/// Link annotations: rectangle in points and target.
links: Vec<(Bounds2, String)>,
@@ -673,170 +556,125 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f6
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
/// 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))
}
/// A rectangle path between two corners (points), if it has an area.
fn rect_path(min: Vec2, max: Vec2) -> Option<Path> {
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()
}
/// 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<Path> {
// 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));
/// 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();
}
}
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.
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();
if s == "-0" {
s = "0".into();
}
surface.pop();
surface.finish();
page.finish();
drawn
out.push_str(&s);
}
/// 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,
fn pt(out: &mut String, p: Vec2) {
num(out, p.x);
out.push(' ');
num(out, p.y);
}
/// 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<OcgId>, to: Option<OcgId>) -> std::result::Result<(), Failure> {
if from.is_some() {
s.end_optional_content().map_err(krilla_failure)?;
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(' ');
}
if let Some(id) = to {
s.start_optional_content(id).map_err(krilla_failure)?;
}
Ok(())
out.push_str(op);
out.push('\n');
}
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.
/// The page's marks; those of primitives on `layers` are marked as optional content
/// (`/OC /L<layer> BDC` … `EMC`).
///
/// 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 in `texts` (filled glyph outlines) are written as text instead: the glyph in its
/// embedded font (`/F<font>`), placed by a text matrix.
///
/// 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);
/// Transparent primitives are drawn with a graphics state of their alpha (`/T<percent> 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<u32, (usize, &cadcraft_render::PlotText)>,
) -> (String, Vec<u8>) {
let marked: std::collections::HashSet<u32> = layers.iter().copied().collect();
let white = Rgb(255, 255, 255);
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
let mut layer: Option<OcgId> = None;
for (i, prim) in list.prims.iter().enumerate() {
let want = list.prim_layers.get(i).and_then(|l| groups.ids.get(l)).copied();
let mut c = String::new();
c.push_str("q\n1 J 1 j\n");
// 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<Rgb> = None;
let mut fill: Option<Rgb> = None;
let mut width: Option<f64> = 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<i> BDC` … `EMC`.
let mut layer: Option<u32> = None;
// Inside a text object (`BT` … `ET`), with this font set.
let mut text_font: Option<Option<usize>> = None;
// The transparency (percent) marks are drawn with, and those used on the page.
let mut alpha: u8 = 0;
let mut alphas: Vec<u8> = 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;
}
if want != layer {
flush(s, &mut batch);
layer_changed(s, layer, want)?;
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");
}
layer = want;
}
let rgb = prim.display_rgb(white);
let raw = list.points(prim);
let rgb = p.display_rgb(white);
let raw = list.points(p);
if raw.iter().any(|q| !q.is_finite()) {
continue;
}
// A wipeout's mask always hides what is under it.
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 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 w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
match prim.kind {
match p.kind {
Kind::Polyline | Kind::Infinite { .. } => {
let pts: Vec<Vec2> = match prim.kind {
let pts: Vec<Vec2> = match p.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, &p.clip_pt) {
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
Some((x, y)) => vec![x, y],
None => continue,
}
@@ -848,117 +686,301 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
if pts.len() < 2 {
continue;
}
// 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);
// 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);
}
for run in runs {
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() 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 stroke != Some(rgb) || width != Some(w) {
flush(&mut c, &mut pending);
if stroke != Some(rgb) {
color(&mut c, rgb, "RG");
stroke = Some(rgb);
}
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);
}
if width != Some(w) {
num(&mut c, w);
c.push_str(" w\n");
width = Some(w);
}
}
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(s, &mut batch);
flush(&mut c, &mut pending);
// A wipeout's mask is paper white.
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);
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);
continue;
}
let mapped: Vec<Vec2> = 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::<Option<Vec<_>>>() else { continue };
for (j, (x, y)) in v.iter().enumerate() {
if j == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
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" });
}
pb.close();
c.push_str("h\n");
any = true;
}
if let Some(path) = pb.finish() {
fill_path(s, &path, rgb, t);
if any {
c.push_str("f\n");
}
}
Kind::Point => {
let Some(q) = raw.first() else { continue };
flush(s, &mut batch);
let half = (w.max(0.5)) / 2.0;
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 fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
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");
}
}
}
flush(s, &mut batch);
layer_changed(s, layer, None)
}
/// 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;
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;
flush(&mut c, &mut pending);
if text_font.is_some() {
c.push_str("ET\n");
}
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();
if layer.is_some() {
c.push_str("EMC\n");
}
c.push_str("Q\n");
(c, alphas)
}
/// 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<String> {
/// The page resources' graphics states for transparencies `alphas` (percent): `/T<percent>`
/// 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<Option<usize>>, font: usize, t: &cadcraft_render::PlotText, map: &Map) {
if text_font.is_none() {
c.push_str("BT\n");
*text_font = Some(None);
}
if *text_font != Some(Some(font)) {
let _ = writeln!(c, "/F{font} 1 Tf");
*text_font = Some(Some(font));
}
let s = map.s * map.k;
let g = &t.glyph;
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
num(c, v);
c.push(' ');
}
pt(c, map.pt(g.origin));
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
}
/// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII,
/// else UTF-16BE with a byte order mark (hex). At most 1000 characters.
fn pdf_string(s: &str) -> String {
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("<FEFF");
for u in s.encode_utf16() {
let _ = write!(out, "{u:04X}");
}
out.push('>');
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<String> {
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::with_capacity(url.len());
let mut out = String::from("(");
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<u8> {
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<Vec<u8>>,
}
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<Vec<u8>>) {
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<Vec<u8>>) -> usize {
let n = self.reserve();
self.set(n, body);
n
}
}
/// `n 0 R` references, space-separated.
fn refs(ids: impl IntoIterator<Item = usize>) -> String {
ids.into_iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().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<u8> {
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<u32, usize> = std::collections::HashMap::new();
let mut ocgs: Vec<(u32, usize)> = Vec::new();
let mut kids = Vec::new();
// Page dictionaries are completed once the fonts are written: (page, its fonts, the rest).
let mut page_dicts: Vec<(usize, Vec<usize>, String, String, 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<usize> = texts.values().map(|(f, _)| *f).collect();
used.sort_unstable();
used.dedup();
let mut props = String::new();
for l in &p.layers {
let Some(layer) = d.layers.get(*l as usize) else { continue };
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<String> = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect();
if !f.is_empty() {
let _ = write!(res, " /Font << {} >>", f.join(" "));
}
objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /Resources <<{res} >> {rest} >>"));
}
objs.set(tree, format!("<< /Type /Pages /Kids [{}] /Count {} >>", refs(kids.iter().copied()), kids.len()));
let mut cat = format!("<< /Type /Catalog /Pages {tree} 0 R");
if !ocgs.is_empty() {
// Optional content needs PDF 1.5; the catalog's version overrides the header's.
let off: Vec<usize> = 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<String> = 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<u8> = 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
}
+151 -40
View File
@@ -1,22 +1,25 @@
//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are
//! 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.
//! written as text in embedded TrueType subsets, so the text can be selected, searched and
//! copied. Each font is a `Type0` font with `Identity-H` encoding over a `CIDFontType2`
//! descendant whose CIDs are the glyph ids (`/CIDToGIDMap /Identity`), with a `ToUnicode`
//! map for copying and searching.
//!
//! Fonts whose licence bits forbid embedding a subset, fonts without TrueType outlines, and
//! anything past the caps on fonts and font data stay as filled outlines.
use std::collections::HashMap;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use cadcraft_fonts::subset::{self, Embedding};
use cadcraft_fonts::subset::{self, FontInfo};
use cadcraft_fonts::ttf::Face;
use cadcraft_render::{DisplayList, PlotText};
use craft_pdf::write::backend::text::Font;
use super::{Objects, num, refs, stream};
/// Most embedded fonts in one file.
const MAX_FONTS: usize = 64;
/// 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;
/// Most embedded font data in one file (uncompressed bytes).
const MAX_FONT_BYTES: usize = 64 << 20;
/// A face's identity: its loaded file and face index.
type FaceKey = (usize, usize, u32);
@@ -25,45 +28,153 @@ fn key(f: &Face) -> FaceKey {
(f.bytes.as_ptr() as usize, f.bytes.len(), f.index)
}
/// The fonts of a document, decided face by face as glyphs are written.
/// An embedded font.
struct Font {
face: Face,
/// Glyphs used, and the character each shows.
chars: BTreeMap<u16, char>,
/// The subset file.
data: Vec<u8>,
info: FontInfo,
}
/// The fonts of a document: those that can be embedded, by face.
#[derive(Default)]
pub(super) struct Fonts {
/// 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<FaceKey, (Face, Option<Font>)>,
count: usize,
bytes: usize,
fonts: Vec<Font>,
by_face: HashMap<FaceKey, Option<usize>>,
}
impl Fonts {
pub(super) fn new(enabled: bool) -> Fonts {
Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 }
/// Collect the glyphs of every plotted text glyph and subset their fonts.
pub(super) fn collect<'a>(lists: impl IntoIterator<Item = &'a DisplayList>) -> Fonts {
let mut used: Vec<(Face, BTreeMap<u16, char>)> = Vec::new();
let mut index: HashMap<FaceKey, usize> = HashMap::new();
for list in lists {
for t in usable(list) {
let k = key(&t.glyph.face);
let i = match index.get(&k) {
Some(i) => *i,
None => {
index.insert(k, used.len());
used.push((t.glyph.face.clone(), BTreeMap::new()));
used.len() - 1
}
};
if let Some((_, chars)) = used.get_mut(i) {
chars.entry(t.glyph.gid).or_insert(t.glyph.ch);
}
}
}
let mut out = Fonts::default();
let mut total = 0usize;
for (face, chars) in used {
let k = key(&face);
let font = (out.fonts.len() < MAX_FONTS)
.then(|| subset::subset(&face, &chars.keys().copied().collect::<BTreeSet<u16>>()).ok())
.flatten()
.filter(|data| total.saturating_add(data.len()) <= MAX_FONT_BYTES)
.and_then(|data| Some(Font { info: subset::info(&face)?, face, chars, data }));
match font {
Some(f) => {
total = total.saturating_add(f.data.len());
out.by_face.insert(k, Some(out.fonts.len()));
out.fonts.push(f);
}
None => {
out.by_face.insert(k, None);
}
}
}
out
}
/// The font a glyph of `face` is written in; `None`: draw its outline.
pub(super) fn font(&mut self, face: &Face) -> Option<Font> {
if !self.enabled {
return None;
}
let k = key(face);
if let Some((_, f)) = self.by_face.get(&k) {
return f.clone();
}
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);
}
self.by_face.insert(k, (face.clone(), font.clone()));
font
pub(super) fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
/// The embedded font (index) a glyph is written in; `None`: draw its outline.
fn font_of(&self, t: &PlotText) -> Option<usize> {
self.by_face.get(&key(&t.glyph.face)).copied().flatten()
}
/// The plotted glyphs of `list` written as text: outline primitive → (font, glyph).
pub(super) fn texts<'a>(&self, list: &'a DisplayList) -> HashMap<u32, (usize, &'a PlotText)> {
usable(list).filter_map(|t| Some((t.prim, (self.font_of(t)?, t)))).collect()
}
/// Write the font objects; returns each font's `Type0` object, by font index.
pub(super) fn write(&self, objs: &mut Objects, compress: bool) -> Vec<usize> {
self.fonts.iter().map(|f| write_font(objs, f, compress)).collect()
}
}
/// The text glyphs of a list that stand for a primitive of it, by primitive.
pub(super) fn texts(list: &DisplayList) -> HashMap<u32, &PlotText> {
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0).map(|t| (t.prim, t)).collect()
/// The text glyphs of a list that stand for a primitive of it.
fn usable(list: &DisplayList) -> impl Iterator<Item = &PlotText> {
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0)
}
/// Six capital letters naming a subset (PDF subset fonts are `ABCDEF+Name`), from its glyphs.
fn subset_tag(f: &Font) -> String {
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
for b in f.info.postscript_name.bytes().chain(f.chars.keys().flat_map(|g| g.to_be_bytes())) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01B3);
}
(0..6).map(|i| char::from(b'A' + ((h >> (i * 8)) % 26) as u8)).collect()
}
fn write_font(objs: &mut Objects, f: &Font, compress: bool) -> usize {
let name = format!("{}+{}", subset_tag(f), f.info.postscript_name);
let file = objs.add(stream(&f.data, compress, &format!(" /Length1 {}", f.data.len())));
let mut bbox = String::new();
for (i, v) in f.info.bbox.iter().enumerate() {
if i > 0 {
bbox.push(' ');
}
num(&mut bbox, v.round());
}
let mut metrics = String::new();
for (k, v) in [("Ascent", f.info.ascent), ("Descent", f.info.descent), ("CapHeight", f.info.cap_height), ("ItalicAngle", f.info.italic_angle)] {
let _ = write!(metrics, " /{k} ");
num(&mut metrics, v.round());
}
let descriptor =
objs.add(format!("<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [{bbox}]{metrics} /StemV 80 /FontFile2 {file} 0 R >>"));
let mut widths = String::new();
for g in f.chars.keys() {
let _ = write!(widths, "{g} [");
num(&mut widths, subset::advance(&f.face, *g).round());
widths.push_str("] ");
}
let cid = objs.add(format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
widths.trim_end()
));
let to_unicode = objs.add(stream(to_unicode(&f.chars).as_bytes(), compress, ""));
objs.add(format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{}] /ToUnicode {to_unicode} 0 R >>",
refs([cid])
))
}
/// A `ToUnicode` CMap from glyph ids (2-byte codes) to the characters they show.
fn to_unicode(chars: &BTreeMap<u16, char>) -> String {
let mut s = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
let entries: Vec<(&u16, &char)> = chars.iter().collect();
// At most 100 entries per block.
for block in entries.chunks(100) {
let _ = writeln!(s, "{} beginbfchar", block.len());
for (g, c) in block {
let _ = write!(s, "<{g:04X}> <");
for u in c.encode_utf16(&mut [0; 2]) {
let _ = write!(s, "{u:04X}");
}
s.push_str(">\n");
}
s.push_str("endbfchar\n");
}
s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
s
}
+62 -102
View File
@@ -4,9 +4,6 @@ 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() });
@@ -421,22 +418,50 @@ fn hostile_export_window_is_an_error() {
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
/// content.
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
fn check_pdf(bytes: &[u8]) -> String {
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()
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()
}
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
pdf_read::read(bytes).media()
}
/// The line widths (`w`) the content sets.
fn widths(content: &str) -> Vec<f64> {
pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect()
let text = String::from_utf8_lossy(bytes).to_string();
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
(v[0], v[1])
}
#[test]
@@ -446,17 +471,16 @@ 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!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
// 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!(!pdf_read::read(&raw).pages[0].compressed);
assert!(pdf_read::read(&bytes).pages[0].compressed);
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
assert!(c2.contains("re W n"));
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
@@ -489,12 +513,11 @@ 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.
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}");
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
assert!(content.contains("0.709 w"));
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(widths(&check_pdf(&off)).contains(&0.0));
assert!(check_pdf(&off).contains("0 w"));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
@@ -2415,16 +2438,15 @@ 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);
// One graphics state: fill alpha 0.5 (strokes stay opaque).
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
let text = String::from_utf8_lossy(&on).to_string();
assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}");
// The line is opaque, the solid at half alpha.
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}");
assert!(content.contains("/T50 gs\n1 0 0 rg"), "{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());
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
let off_text = String::from_utf8_lossy(&off).to_string();
assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n"));
// On, but nothing transparent: the same bytes as off.
let opaque = transparent_over_line(0);
assert_eq!(
@@ -2443,13 +2465,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});
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());
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState"));
d.layouts[0].page.plot_transparency = true;
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:?}");
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}");
// The plot's own option wins over the page setup.
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
let off = serde_json::json!({"compress": false, "plotTransparency": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState"));
}
#[test]
@@ -2504,14 +2526,11 @@ 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!(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}");
assert!(glass.contains("/T50 gs"), "{glass}");
assert_eq!(glass.matches("S\n").count(), 2, "{glass}");
// Opaque: one path as before, whether the option is on or off.
let opaque = content(&crossing(0), true);
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque.matches("S\n").count(), 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);
@@ -2548,15 +2567,11 @@ 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!(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}");
assert!(half.contains("/T50 gs"), "{half}");
assert_eq!(half.matches("S\n").count(), 2, "{half}");
// Opaque: one path, the same with the option on or off.
let opaque = content(&crossing_block(0), true);
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(opaque, content(&crossing_block(0), false));
assert_eq!(content(&crossing_block(50), false), opaque);
}
@@ -2601,61 +2616,6 @@ 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<f64> =
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();
+2 -2
View File
@@ -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 doc = crate::pdf_read::read(&bytes);
let text = String::from_utf8_lossy(&bytes);
let xs: Vec<f64> =
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).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");
-2
View File
@@ -45,8 +45,6 @@ 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;
+16 -18
View File
@@ -12,22 +12,18 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
d.add(&Space::Model, Common::default(), k).unwrap();
}
fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc {
fn pdf_text(d: &Drawing, opts: Value) -> String {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
m.extend(e.clone());
}
crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap())
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
}
/// The plot area's clip rectangle (`x y w h`) of the first page.
fn clip(doc: &crate::pdf_read::Doc) -> Vec<f64> {
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<String> {
doc.pages[0].links.iter().map(|l| l.uri.clone()).collect()
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
fn clip(pdf: &str) -> Vec<f64> {
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()
}
#[test]
@@ -36,17 +32,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_doc(&d, json!({})));
let plain = clip(&pdf_text(&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_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
let r = clip(&pdf_text(&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_doc(&d, json!({"margins": bad}))), plain, "{bad}");
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
}
}
@@ -111,11 +107,13 @@ 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 = uris(&pdf_doc(&read, json!({})));
assert_eq!(pdf, ["https://example.com/plan"], "only the web link");
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"));
// Turned off, and given explicitly for the same object (the explicit one wins).
assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty());
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
let first = read.model.iter().next().unwrap().handle.hex();
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:?}");
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}");
}
+118 -131
View File
@@ -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 read the written file back.
//! linked entities get link annotations. The tests parse the written objects.
use cadcraft_color::Color;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport};
@@ -9,19 +9,57 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets};
use cadcraft_io::{PdfOptions, plot};
use serde_json::json;
use crate::pdf_read::{self, Doc};
fn read(bytes: &[u8]) -> Doc {
pdf_read::read(bytes)
/// 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
}
fn ocg_names(doc: &Doc) -> Vec<String> {
doc.ocgs.iter().map(|g| g.name.clone()).collect()
fn object(objs: &[(usize, String)], n: usize) -> &str {
&objs.iter().find(|(i, _)| *i == n).unwrap().1
}
/// The `BDC` marks of a content: the property resource each one names.
fn marks(content: &str) -> Vec<String> {
pdf_read::ops(content).into_iter().filter(|o| o.op == "BDC").filter_map(|o| o.names.get(1).cloned()).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<String> {
pages(objs).iter().map(|p| object(objs, reference(p, "/Contents")).to_string()).collect()
}
fn pages(objs: &[(usize, String)]) -> Vec<String> {
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<String> {
objs.iter().filter_map(|(_, b)| b.strip_prefix("<< /Type /OCG /Name ").map(|r| r.trim_end_matches(" >>").to_string())).collect()
}
fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle {
@@ -71,66 +109,45 @@ fn raw(extra: serde_json::Value) -> serde_json::Value {
#[test]
fn drawing_layers_become_pdf_layers() {
let d = layered();
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
// Plotted layers only, in drawing order of first use; block contents on layer 0 are on
// 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 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<usize> = 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 page maps its marks to the groups, and the marked sections are balanced.
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:?}");
// 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 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");
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");
}
#[test]
fn layers_that_are_off_are_included_hidden_on_request() {
let d = layered();
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);
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"));
}
#[test]
@@ -152,50 +169,43 @@ 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 names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap()));
let objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
// 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 doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
let bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap();
// "all": model (it has objects), then every layout in tab order.
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.
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 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 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);
// 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"));
// The library call does the same.
let o = PdfOptions { compress: false, ..PdfOptions::default() };
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());
let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>"));
}
#[test]
@@ -220,51 +230,28 @@ fn hyperlinks_become_link_annotations() {
{"handle": h.hex(), "url": "https://example.com/ü"},
{"url": "https://example.com/no-handle"},
]);
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");
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 ["));
// The rectangle is inside the A4 landscape sheet and wide like the line.
let v = annots[0].rect;
let r = annots[0].split("/Rect [").nth(1).unwrap().split(']').next().unwrap();
let v: Vec<f64> = r.split_whitespace().map(|x| x.parse().unwrap()).collect();
assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}");
}
fn unusual_names() -> Drawing {
#[test]
fn unusual_layer_names_and_titles_are_escaped() {
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));
}
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));
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"title": "Plan ü"}))).unwrap());
let names = ocg_names(&objs);
assert_eq!(names, ["(a\\(b\\)\\\\c)", "<FEFF005700E4006E00640065>", "<FEFF65E5672C>"]);
assert!(objs.iter().any(|(_, b)| b.contains("/Title <FEFF0050006C0061006E002000FC>")));
}
+3 -4
View File
@@ -14,10 +14,9 @@ fn plotted_mm(opts: Value) -> f64 {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap();
let doc = crate::pdf_read::read(&bytes);
let xs: Vec<f64> =
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());
let text = String::from_utf8_lossy(&bytes).into_owned();
let xs: Vec<f64> = 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}");
(xs[1] - xs[0]).abs() * 25.4 / 72.0
}
+2 -2
View File
@@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing {
d
}
/// The page content of Layout1's PDF with extra plot options.
/// The uncompressed PDF of Layout1 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();
crate::pdf_read::read(&bytes).content().to_string()
String::from_utf8_lossy(&bytes).into_owned()
}
#[test]
+77 -91
View File
@@ -10,11 +10,6 @@ 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<u8>, x: u16) {
@@ -51,9 +46,8 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec<u8> {
g
}
/// 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<u8> {
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`.
fn test_font(fs_type: u16) -> Vec<u8> {
let glyphs = [Vec::new(), simple_glyph(&[(50, 0), (550, 0), (300, 700)]), simple_glyph(&[(50, 0), (550, 0), (550, 700), (50, 700)])];
let mut glyf = Vec::new();
let mut loca = Vec::new();
@@ -88,7 +82,7 @@ fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
be16(&mut maxp, v);
}
let mut hmtx = Vec::new();
for _ in 0..metrics {
for _ in 0..3 {
be16(&mut hmtx, 600);
be16(&mut hmtx, 50);
}
@@ -148,14 +142,12 @@ fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
out
}
/// 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).
/// Register the test fonts once: an embeddable one and one with a restricted licence.
fn fonts() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0, 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));
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002));
});
}
@@ -186,72 +178,77 @@ 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, read back.
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc {
/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`).
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) {
m.extend(e.clone());
}
pdf_read::read(&plot(d, space, &o).unwrap())
let bytes = plot(d, space, &o).unwrap();
check_xref(&bytes);
bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()
}
/// 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 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 codes shown, in order (two bytes each).
fn codes(doc: &Doc) -> Vec<u32> {
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::<Vec<_>>())
/// The text matrices (`a b c d e f Tm`) of the content, in order.
fn matrices(pdf: &str) -> Vec<[f64; 6]> {
pdf.lines()
.filter(|l| l.ends_with(" Tj") && l.contains(" Tm "))
.map(|l| {
let v: Vec<f64> = l.split(" Tm ").next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
[v[0], v[1], v[2], v[3], v[4], v[5]]
})
.collect()
}
/// 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()
/// The embedded font file (FontFile2 stream data).
fn font_file(pdf: &str, bytes_of: &[u8]) -> Vec<u8> {
let i = pdf.find("/Length1 ").expect("a FontFile2 stream");
let head = &pdf[..i];
let len_at = head.rfind("/Length ").unwrap() + 8;
let len: usize = pdf[len_at..].split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = pdf[i..].find("stream\n").unwrap() + i + 7;
bytes_of[start..start + len].to_vec()
}
#[test]
fn truetype_text_is_embedded_as_text() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0);
let doc = pdf(&d, &Space::Model, json!({}));
let page = &doc.pages[0];
let p = pdf(&d, &Space::Model, json!({}));
// A Type0 font over a CIDFontType2 subset with a ToUnicode map.
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]));
assert!(p.contains("/Subtype /Type0") && p.contains("/Encoding /Identity-H") && p.contains("/Subtype /CIDFontType2"), "{p}");
assert!(p.contains("/CIDToGIDMap /Identity") && p.contains("/FontFile2 ") && p.contains("/ToUnicode "));
let base = p.split("/BaseFont /").nth(1).unwrap().split_whitespace().next().unwrap();
assert!(base.len() == 11 && base.as_bytes()[6] == b'+' && base[..6].chars().all(|c| c.is_ascii_uppercase()), "{base}");
assert!(p.contains("<0001> <0041>") && p.contains("<0002> <0042>"), "ToUnicode maps glyphs to A and B");
assert!(p.contains("/W [1 [600] 2 [600]]"), "widths");
// The page uses the font; four glyphs written as text, in a text object.
assert!(p.contains("/Font << /F0 "));
let tj: Vec<&str> = p.lines().filter(|l| l.ends_with(" Tj")).map(|l| l.rsplit(' ').nth(1).unwrap()).collect();
assert_eq!(tj, ["<0001>", "<0002>", "<0002>", "<0001>"]);
assert!(p.contains("BT\n/F0 1 Tf\n") && p.contains("ET\n"));
assert_eq!(p.matches("BT\n").count(), p.matches("ET\n").count());
// No filled outlines left for them.
assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content);
assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}");
// The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be.
let m = matrices(&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 m = matrices(&p);
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0));
let step = m[1][4] - m[0][4];
assert!((step - 0.6 * m[0][0]).abs() < 0.01, "advance {step} for em {}", m[0][0]);
assert!(m.windows(2).all(|w| (w[0][5] - w[1][5]).abs() < 1e-3), "one baseline");
@@ -261,12 +258,21 @@ 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 doc = pdf(&d, &Space::Model, json!({}));
let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file");
let bytes = plot(&d, &Space::Model, &json!({"compress": false})).unwrap();
let p: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let file = font_file(&p, &bytes);
assert!(p.contains(&format!("/Length1 {}", file.len())));
let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses");
assert_eq!(cadcraft_fonts::subset::embedding(&face), cadcraft_fonts::subset::Embedding::Allowed);
// Glyph A kept, B (unused) left out: the subset is smaller than the font.
assert!(file.len() < test_font(0, 3).len());
// Glyph A kept, B (unused) emptied: the subset is smaller than the font.
assert!(file.len() < test_font(0).len());
// Whole-file checksum adjusted.
let sum = file.chunks(4).fold(0u32, |s, c| {
let mut w = [0u8; 4];
w[..c.len()].copy_from_slice(c);
s.wrapping_add(u32::from_be_bytes(w))
});
assert_eq!(sum, 0xB1B0_AFBA);
}
#[test]
@@ -277,7 +283,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, "{m:?}");
assert_eq!(m.len(), 3);
// Rotated a quarter turn: the baseline points up, "up" points left.
let [a, b, c, dd, _, _] = m[0];
assert!(a.abs() < 1e-3 && b > 0.0 && c < 0.0 && dd.abs() < 1e-3, "{:?}", m[0]);
@@ -291,29 +297,21 @@ 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!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines");
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines");
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({"text": "geometry"}));
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry");
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry");
// The stroke font is always geometry.
let mut d = drawing();
text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0);
assert!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n"));
}
#[test]
@@ -339,26 +337,14 @@ 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!(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)));
assert_eq!(tj, 4, "{p}");
assert!(p.contains("h\nf\n"), "the cut glyph is an outline");
// Text sits in its layer's marked content.
let first_bt = p.find("BT\n").unwrap();
assert!(p[..first_bt].contains("/OC /L0 BDC"));
}
#[test]
-518
View File
@@ -1,518 +0,0 @@
//! 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>,
/// Page labels, one per page (empty when the file has none).
pub labels: Vec<String>,
pub title: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
/// Optional content groups (PDF layers) in `/OCProperties` order.
pub ocgs: Vec<Ocg>,
/// The catalog's `/PageMode`.
pub page_mode: Option<String>,
}
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<u8>,
/// 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<Link>,
pub fonts: Vec<Font>,
/// 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<String>,
/// The descendant font's subtype (Type0 fonts).
pub descendant: Option<String>,
/// The descendant font's `/CIDToGIDMap` name.
pub cid_to_gid: Option<String>,
/// Glyph widths (`/W` of the descendant font): code and width.
pub widths: Vec<(u32, f64)>,
/// The decoded `ToUnicode` CMap.
pub to_unicode: Option<String>,
/// The embedded TrueType file (`FontFile2`).
pub font_file: Option<Vec<u8>>,
}
/// 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::<Vec<_>>()),
None => b.iter().map(|c| char::from(*c)).collect(),
}
}
fn name(d: &Dict<'_>, key: &[u8]) -> Option<String> {
d.get::<Name<'_>>(key).map(|n| n.as_str().to_string())
}
fn rect(a: &Array<'_>) -> [f64; 4] {
let v: Vec<f64> = a.iter::<f64>().collect();
assert_eq!(v.len(), 4, "a rectangle: {v:?}");
[v[0], v[1], v[2], v[3]]
}
fn decoded(s: &Stream<'_>) -> Vec<u8> {
s.decoded().expect("a stream that decodes").to_vec()
}
fn font(d: &Dict<'_>) -> Font {
let descendant = d.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next());
let font_file =
descendant.as_ref().unwrap_or(d).get::<Dict<'_>>(b"FontDescriptor").and_then(|fd| fd.get::<Stream<'_>>(b"FontFile2")).map(|s| decoded(&s));
let mut widths = Vec::new();
if let Some(w) = descendant.as_ref().and_then(|c| c.get::<Array<'_>>(b"W")) {
let mut it = w.flex_iter();
while let Some(first) = it.next::<u32>() {
if let Some(list) = it.next::<Array<'_>>() {
widths.extend(list.iter::<f64>().enumerate().map(|(i, v)| (first + i as u32, v)));
} else {
let (last, v) = (it.next::<u32>().expect("a /W range end"), it.next::<f64>().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::<Stream<'_>>(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::<Dict<'_>>(b"Resources");
let dicts = |key: &[u8]| -> Vec<Dict<'_>> {
let Some(d) = resources.as_ref().and_then(|r| r.get::<Dict<'_>>(key)) else { return Vec::new() };
let keys: Vec<Vec<u8>> = d.keys().map(|k| k.deref().to_vec()).collect();
keys.iter().filter_map(|k| d.get::<Dict<'_>>(k.as_slice())).collect()
};
let alphas = dicts(b"ExtGState").iter().map(|g| (g.get::<f64>(b"CA").unwrap_or(1.0), g.get::<f64>(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::<Dict<'_>>(b"Properties")) {
Some(d) => d
.keys()
.filter_map(|k| {
let ocg = d.get::<Dict<'_>>(k.deref())?;
Some((k.as_str().to_string(), ocg.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default()))
})
.collect(),
None => Vec::new(),
};
let links = raw
.get::<Array<'_>>(b"Annots")
.map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>())
.unwrap_or_default()
.iter()
.filter(|a| name(a, b"Subtype").as_deref() == Some("Link"))
.map(|a| Link {
rect: rect(&a.get::<Array<'_>>(b"Rect").expect("a link rectangle")),
uri: a
.get::<Dict<'_>>(b"A")
.and_then(|act| act.get::<PdfString<'_>>(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::<Stream<'_>>(b"Contents")
.map(|c| vec![c])
.or_else(|| raw.get::<Array<'_>>(b"Contents").map(|a| a.iter::<Stream<'_>>().collect()))
.unwrap_or_default()
.iter()
.any(|c| c.dict().contains_key(b"Filter")),
alphas,
links,
fonts,
properties,
});
}
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).expect("a catalog");
let mut starts: Vec<(usize, String)> = Vec::new();
if let Some(nums) = catalog.get::<Dict<'_>>(b"PageLabels").and_then(|l| l.get::<Array<'_>>(b"Nums")) {
let mut it = nums.flex_iter();
while let Some(i) = it.next::<i64>() {
let d = it.next::<Dict<'_>>().expect("a page label dictionary");
starts.push((i as usize, d.get::<PdfString<'_>>(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::<Dict<'_>>(b"OCProperties") {
let off: Vec<_> = oc
.get::<Dict<'_>>(b"D")
.and_then(|d| d.get::<Array<'_>>(b"OFF"))
.map(|a| a.iter::<Dict<'_>>().filter_map(|g| g.obj_id()).collect())
.unwrap_or_default();
for g in oc.get::<Array<'_>>(b"OCGs").map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>()).unwrap_or_default() {
ocgs.push(Ocg {
name: g.get::<PdfString<'_>>(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<Vec<u8>>| 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<f64>,
/// Name operands, without the slash.
pub names: Vec<String>,
}
/// The operators of content text in order, each with its numeric operands (other operands
/// are skipped).
pub fn ops(content: &str) -> Vec<Op> {
let b: Vec<char> = 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::<f64>() {
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<Op> {
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<Op> {
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<Vec<u8>> {
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<u8> = 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<u8> = 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<u16> = (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
}