mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e43beaf9 |
Generated
+3
-224
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most objects one call looks at (a hostile drawing can be huge).
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_param, str_param};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) mod iso;
|
||||
|
||||
@@ -12,7 +12,8 @@ use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_req, size_param, str_param};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Caps for hostile parameters.
|
||||
|
||||
@@ -5,7 +5,8 @@ use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::{line, lwpoly, v3};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, point_req, str_param};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -4,7 +4,8 @@ use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, point_param, points_param, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -379,7 +380,7 @@ impl Interactive for MeasureM {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, point_param, points_param, str_param, targets};
|
||||
|
||||
#[test]
|
||||
fn calculator() {
|
||||
|
||||
@@ -6,7 +6,8 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::machines::{SelectRun, number};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_req, ok, point_req, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
mod named_views;
|
||||
|
||||
+286
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>")));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user