Compare commits

..
Author SHA1 Message Date
aiongg 41d9b5e957 PDF: plot with craft-pdf's krilla writer (#2029)
* Depend on craft-pdf (krilla writer, reader for tests) at craft-libs 9f75d3dc

The root patches pin the patched krilla and hayro-syntax/hayro-jbig2 to
the same revision, as craft-pdf requires.

* Plot PDFs with craft-pdf's krilla writer

The hand-written object, xref, stream and font writer goes. Each sheet's
display list is built, written as its page and dropped before the next.
Strokes with the same colour, width and transparency stay one path, and
each filled primitive is one path of its triangles. Text in embeddable
TrueType fonts is drawn as glyphs; krilla subsets the fonts and writes
their ToUnicode maps, so the hand-written subsetter goes too and
cadcraft-fonts keeps only the fsType embedding check. Primitives with a
point that is not finite or beyond 1e7 pt are left out. A font krilla
cannot embed after all makes the export retry with text as outlines.

Optional content (PDF layers) is not written until craft-libs' writer
supports it: the layer transitions go through one empty function, layers
that are off are not plotted, and the layer tests are ignored.

The tests read the PDF back with craft-pdf's reader.

* Pin craft-pdf and its krilla and hayro patches to craft-libs cd160e73

cd160e73 adds optional content groups to the patched krilla.

* Write drawing layers as PDF optional content groups

Each drawing layer a page draws on gets one group, named after the layer
and shared by all pages, created before the first page that uses it. A
group starts hidden when its layer is off in the drawing, so `offLayers`
again plots layers that are off on hidden PDF layers. At most 4096
groups per file; objects on further layers print unmarked. A single
sheet with groups opens with the viewer's layer panel (/PageMode /UseOC).
Layer changes end and start the marked content, and the last mark ends
before the page finishes.

The layer and text tests that check the groups run again, and a test
covers the group cap.

* Plot a zero-length line as a round dot in PDFs

krilla leaves out a path without extent, so a lone zero-length line drew
nothing. It is filled as the dot its round-capped stroke would make, as
wide as the stroke, with 0.25 pt for a hairline.

* PDF: retry as outlines when the writer fails inside a font; cap layer names; restore two test checks

* Tests: read plotted PDFs back through the shared reader in plot_layout_scale and plot_styles

* Pin craft-pdf, krilla, hayro-syntax and hayro-jbig2 to craft-libs main (7895f5e)

* PDF tests: read path coordinates in page space; cover the font retry and out-of-range points

- pdf_read maps path operands through the CTM (`page_ops`, used by `drawn` and
  `clip_rect`) and checks that the plot area is drawn in page space.
- The 1:1 layout test finds the viewport corner at (72, 72) in page space, so a
  mirrored page fails it.
- A font with a short hmtx passes the writer's font checks but fails subsetting;
  the plot retries and draws its text as outlines.
- A primitive with a point past 1e7 pt is left out, and the rest still plots.

* PDF: clip a primitive with a far vertex to the plot area instead of leaving it out

A polyline or fill with a point past the writer's f32 range now keeps the
parts inside the plot area (polylines with the area widened by the stroke
width), each piece its own subpath. Primitives in range are written as before.
The test reader requires a decodable content stream.
2026-10-24 08:38:46 -04:00
21 changed files with 1519 additions and 1114 deletions
Generated
+224 -3
View File
@@ -434,6 +434,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-udev"
version = "0.1.2"
@@ -674,6 +680,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -868,6 +875,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
@@ -948,6 +961,16 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1311,7 +1334,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1427,6 +1450,15 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
dependencies = [
"num-traits",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
@@ -1439,6 +1471,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.12.7"
@@ -1582,6 +1623,16 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1775,6 +1826,39 @@ dependencies = [
"foldhash",
]
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
[[package]]
name = "heapless"
version = "0.8.0"
@@ -1932,6 +2016,22 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2092,6 +2192,32 @@ dependencies = [
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"image-webp",
"imagesize",
"indexmap",
"pdf-writer",
"png",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path",
"xmp-writer",
"yoke",
"zune-jpeg",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -2827,6 +2953,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pdf-writer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
dependencies = [
"bitflags 2.13.2",
"itoa",
"memchr",
"ryu",
]
[[package]]
name = "peniko"
version = "0.6.1"
@@ -3153,6 +3291,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"font-types 0.11.3",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
@@ -3160,7 +3308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.7",
"once_cell",
]
@@ -3172,7 +3320,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"font-types",
"font-types 0.12.7",
"once_cell",
"yoke",
]
@@ -3461,6 +3609,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"read-fonts 0.39.2",
]
[[package]]
name = "skrifa"
version = "0.44.0"
@@ -3604,6 +3762,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
dependencies = [
"kurbo",
"rustc-hash 2.1.3",
"skrifa 0.42.1",
"write-fonts",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -3758,6 +3939,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -3894,6 +4081,21 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -4689,6 +4891,19 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write-fonts"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
dependencies = [
"font-types 0.11.3",
"indexmap",
"kurbo",
"log",
"read-fonts 0.39.2",
]
[[package]]
name = "writeable"
version = "0.6.4"
@@ -4758,6 +4973,12 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmp-writer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
+8
View File
@@ -26,6 +26,9 @@ cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false }
# foundation
thiserror = "2"
@@ -58,6 +61,11 @@ egui-wgpu = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
[workspace.lints.rust]
unsafe_code = "forbid"
+1 -2
View File
@@ -13,8 +13,7 @@ use cadcraft_geom::{TAU, Vec2, Vec3, ccw_sweep, norm_angle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, number};
use super::{CommandSpec, bad, targets};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+1 -2
View File
@@ -14,8 +14,7 @@ use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfi
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::{CommandSpec, Result, bad, point_req, str_param};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step};
/// Where plotter configuration files live. Names are file names ending in
+1 -2
View File
@@ -10,8 +10,7 @@ use cadcraft_doc::{EntityKind, Lineweight, Transparency};
use cadcraft_geom::Vec3;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, f64_req, ok, point_req, point_value, str_param, targets};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+1 -2
View File
@@ -7,8 +7,7 @@ use serde_json::{Value, json};
use super::super::helpers::support_text;
use super::super::qselect::wildcard;
use super::{Result, Session, bad, bool_or, str_param};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Step};
/// Names no file may define: they are not linetype definitions.
+1 -2
View File
@@ -6,8 +6,7 @@
use cadcraft_doc::{Entity, EntityKind, Handle};
use serde_json::{Value, json};
use super::super::{bad, targets};
use super::super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Which properties MATCHPROP copies (its Settings).
+2 -3
View File
@@ -5,8 +5,7 @@ use cadcraft_color::Color;
use cadcraft_doc::{Drawing, Entity, Handle};
use serde_json::{Value, json};
use super::{CommandSpec, bad, str_param};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -323,7 +322,7 @@ fn run_info(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use serde_json::json;
use super::{CommandSpec, bad, str_param};
use super::*;
fn session() -> Session {
let mut s = Session::new();
+8 -286
View File
@@ -1,25 +1,13 @@
//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document
//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs
//! become empty), so text written with the original ids shows the same glyphs.
//!
//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted
//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as
//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset
//! here either.
//! Whether a font may be embedded as a subset in exported files (PDF), from its licence bits
//! (OS/2 `fsType`): fonts marked "restricted licence", "no subsetting" or "bitmap only" are
//! never embedded; callers draw their text as outlines instead. Fonts without TrueType outlines
//! (CFF-flavoured OpenType) aren't embedded either.
use std::collections::BTreeSet;
use skrifa::instance::{LocationRef, Size};
use skrifa::FontRef;
use skrifa::raw::types::Tag;
use skrifa::{FontRef, MetadataProvider};
use crate::ttf::Face;
/// Largest subset written (bytes).
pub const MAX_SUBSET_BYTES: usize = 32 << 20;
/// Most components followed in one composite glyph, and most glyphs a subset may reach.
const MAX_COMPONENTS: usize = 256;
/// Whether a face may be embedded as a subset.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Embedding {
@@ -35,40 +23,17 @@ pub enum Embedding {
NotTrueType,
}
/// Why a subset couldn't be made.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SubsetError {
/// The font may not be embedded as a subset.
NotAllowed(Embedding),
/// A table is missing or malformed.
Malformed(&'static str),
/// The subset would be larger than [`MAX_SUBSET_BYTES`].
TooLarge,
}
fn font(face: &Face) -> Option<FontRef<'_>> {
FontRef::from_index(&face.bytes, face.index).ok()
}
fn tag(t: &[u8; 4]) -> Tag {
Tag::new(t)
}
fn table<'a>(f: &FontRef<'a>, t: &[u8; 4]) -> Option<&'a [u8]> {
f.table_data(tag(t)).map(|d| d.as_bytes())
f.table_data(Tag::new(t)).map(|d| d.as_bytes())
}
fn u16_at(b: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
}
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
}
/// The embedding permission of a face.
pub fn embedding(face: &Face) -> Embedding {
let Some(f) = font(face) else { return Embedding::NotTrueType };
let Ok(f) = FontRef::from_index(&face.bytes, face.index) else { return Embedding::NotTrueType };
if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() {
return Embedding::NotTrueType;
}
@@ -84,257 +49,14 @@ pub fn embedding(face: &Face) -> Embedding {
}
}
/// Font-wide metrics for a font descriptor, in units of 1/1000 em.
#[derive(Clone, Debug, PartialEq)]
pub struct FontInfo {
/// PostScript name: letters, digits and hyphens only (never empty).
pub postscript_name: String,
/// Glyph bounding box: x min, y min, x max, y max.
pub bbox: [f64; 4],
pub ascent: f64,
pub descent: f64,
pub cap_height: f64,
/// Degrees, counter-clockwise from vertical.
pub italic_angle: f64,
}
/// A finite value or 0.
fn fin(v: f32) -> f64 {
let v = f64::from(v);
if v.is_finite() { v } else { 0.0 }
}
/// The descriptor metrics of a face.
pub fn info(face: &Face) -> Option<FontInfo> {
let f = font(face)?;
let m = f.metrics(Size::new(1000.0), LocationRef::default());
let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]);
let name: String = f
.localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
.english_or_first()
.map(|s| s.to_string())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(63)
.collect();
Some(FontInfo {
postscript_name: if name.is_empty() { "Font".into() } else { name },
bbox,
ascent: fin(m.ascent),
descent: fin(m.descent),
cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72),
italic_angle: fin(m.italic_angle),
})
}
/// The advance width of glyph `gid` in units of 1/1000 em.
pub fn advance(face: &Face, gid: u16) -> f64 {
font(face)
.and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid))))
.map(fin)
.unwrap_or(0.0)
}
/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length.
fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option<Vec<usize>> {
let mut v = Vec::with_capacity(n + 1);
for i in 0..=n {
let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 };
v.push(o.min(glyf_len));
}
Some(v)
}
/// The data of glyph `g` (empty when its range is bad).
fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] {
match (offsets.get(g), offsets.get(g + 1)) {
(Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]),
_ => &[],
}
}
/// The glyph ids a composite glyph is built from.
fn components(data: &[u8]) -> Vec<u16> {
let mut out = Vec::new();
// numberOfContours < 0: a composite.
if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) {
return out;
}
let mut pos = 10;
while out.len() < MAX_COMPONENTS {
let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break };
out.push(gid);
pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 };
pos += if flags & 0x0008 != 0 {
2
} else if flags & 0x0040 != 0 {
4
} else if flags & 0x0080 != 0 {
8
} else {
0
};
if flags & 0x0020 == 0 {
break;
}
}
out
}
/// The sum of a table's big-endian 32-bit words (zero-padded).
fn checksum(data: &[u8]) -> u32 {
data.chunks(4).fold(0u32, |sum, c| {
let mut w = [0u8; 4];
for (d, s) in w.iter_mut().zip(c) {
*d = *s;
}
sum.wrapping_add(u32::from_be_bytes(w))
})
}
/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The
/// other glyphs keep their ids but have no outline.
pub fn subset(face: &Face, gids: &BTreeSet<u16>) -> Result<Vec<u8>, SubsetError> {
let allowed = embedding(face);
if allowed != Embedding::Allowed {
return Err(SubsetError::NotAllowed(allowed));
}
let f = font(face).ok_or(SubsetError::Malformed("font"))?;
let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?;
let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?;
let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?;
let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?;
let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?);
let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0;
let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?;
// The glyphs to keep: asked for, .notdef, and composite components (transitively).
let mut keep: BTreeSet<u16> = BTreeSet::new();
let mut todo: Vec<u16> = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect();
while let Some(g) = todo.pop() {
if !keep.insert(g) {
continue;
}
for c in components(glyph_data(glyf, &offsets, usize::from(g))) {
if usize::from(c) < n && !keep.contains(&c) {
todo.push(c);
}
}
}
let mut new_glyf: Vec<u8> = Vec::new();
let mut new_loca: Vec<u8> = Vec::with_capacity((n + 1) * 4);
for g in 0..n {
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) {
new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g));
while !new_glyf.len().is_multiple_of(4) {
new_glyf.push(0);
}
}
if new_glyf.len() > MAX_SUBSET_BYTES {
return Err(SubsetError::TooLarge);
}
}
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
let mut new_head = head.to_vec();
// checkSumAdjustment is set once the file is complete; offsets are long now.
new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]);
new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes());
let mut tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())];
for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] {
if let Some(d) = table(&f, t) {
tables.push((*t, d.to_vec()));
}
}
if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") {
return Err(SubsetError::Malformed("hhea/hmtx"));
}
tables.sort_by_key(|t| t.0);
let out = sfnt(&tables);
if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) }
}
/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the
/// `head` checksum adjustment.
fn sfnt(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let count = tables.len() as u16;
let mut pow = 1u16;
let mut sel = 0u16;
while pow.saturating_mul(2) <= count {
pow = pow.saturating_mul(2);
sel += 1;
}
let range = pow.saturating_mul(16);
let mut out = Vec::new();
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] {
out.extend_from_slice(&v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
let mut head_at = None;
for (t, d) in tables {
out.extend_from_slice(t);
out.extend_from_slice(&checksum(d).to_be_bytes());
out.extend_from_slice(&(offset as u32).to_be_bytes());
out.extend_from_slice(&(d.len() as u32).to_be_bytes());
offset += d.len().div_ceil(4) * 4;
}
for (t, d) in tables {
if t == b"head" {
head_at = Some(out.len());
}
out.extend_from_slice(d);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
if let Some(h) = head_at {
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
if let Some(slot) = out.get_mut(h + 8..h + 12) {
slot.copy_from_slice(&adj.to_be_bytes());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composite_components_are_followed_and_bounded() {
// numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first).
let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]);
g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]);
assert_eq!(components(&g), [5, 7]);
// Truncated and endless (always MORE_COMPONENTS) data stay bounded.
assert_eq!(components(&g[..14]), [5]);
let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
for _ in 0..2000 {
endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]);
}
assert_eq!(components(&endless).len(), MAX_COMPONENTS);
assert!(components(&[0, 1, 0]).is_empty());
}
#[test]
fn junk_fonts_are_not_embedded() {
let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 };
assert_eq!(embedding(&junk), Embedding::NotTrueType);
assert!(subset(&junk, &BTreeSet::from([1])).is_err());
assert!(info(&junk).is_none());
assert_eq!(advance(&junk, 3), 0.0);
}
#[test]
fn sfnt_directory_and_checksums() {
let mut head = vec![0u8; 54];
head[12..16].copy_from_slice(&0x5F0F_3CF5u32.to_be_bytes());
let out = sfnt(&[(*b"head", head), (*b"maxp", vec![0, 0, 0x50, 0, 0, 1])]);
assert_eq!(u16_at(&out, 4), Some(2));
assert_eq!(out.len() % 4, 0);
// The whole file sums to the magic number once the adjustment is in.
assert_eq!(checksum(&out), 0xB1B0_AFBA);
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType);
}
}
+3
View File
@@ -20,9 +20,12 @@ thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
miniz_oxide = "0.9"
craft-pdf = { workspace = true, features = ["write"] }
quick-xml = { workspace = true }
[dev-dependencies]
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
acadrust = "0.6.5"
tiny-skia = { workspace = true }
# Reads plotted PDFs back.
craft-pdf = { workspace = true, features = ["read"] }
+360 -382
View File
@@ -1,11 +1,12 @@
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file
//! access, so it runs on the web too).
//! Vector PDF plotting (PLOT / EXPORTPDF), written with craft-pdf's PDF writer (krilla; pure
//! Rust, no file access, so it runs on the web too).
//!
//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given
//! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise).
//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
//! paper, and line widths come from lineweights when enabled. The page setup's plot style
//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights.
//! Lines are stroked paths, fills are filled paths, colours are RGB with colour 7 printing
//! black on white paper, and line widths come from lineweights when enabled. The page setup's
//! plot style table (or the `plotStyleTable` option) sets the printed colours, linetypes and
//! lineweights.
//!
//! Several sheets go into one file ([`pdf_sheets`], the `layouts` option), each page labelled
//! with its layout name. Drawing layers become PDF layers (optional content groups) that a PDF
@@ -19,6 +20,19 @@ use cadcraft_geom::{Bounds2, Vec2};
use cadcraft_render::{Kind, Sheet, clip, paper};
use serde_json::Value;
use craft_pdf::write::backend as kr;
use craft_pdf::write::{self, Metadata, WriteError, Writer};
use kr::action::LinkAction;
use kr::annotation::{Annotation, LinkAnnotation, Target};
use kr::error::KrillaError;
use kr::geom::{Path, PathBuilder, Point, Rect, Transform};
use kr::num::NormalizedF32;
use kr::optional_content::OcgId;
use kr::page::{PageLabel, PageSettings};
use kr::paint::{Fill, FillRule, LineCap, LineJoin, Stroke};
use kr::surface::Surface;
use kr::text::{GlyphId, KrillaGlyph};
use crate::{IoError, Result};
mod fonts;
@@ -31,9 +45,13 @@ const MAX_SHEET_MM: f64 = 5080.0;
pub const MAX_PAGES: usize = 256;
/// Most PDF layers in one file; objects on further layers print without one.
const MAX_LAYERS: usize = 4096;
/// Longest PDF layer name, in characters.
const MAX_LAYER_NAME: usize = 1000;
/// Most link annotations in one file, and the longest link target (bytes).
const MAX_LINKS: usize = 10_000;
const MAX_URL: usize = 2048;
/// Longest title and page label written (characters).
const MAX_TEXT: usize = 1000;
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
/// model space).
@@ -64,8 +82,8 @@ pub struct PdfOptions {
pub window: Option<Bounds2>,
/// Drawing layers become PDF layers (optional content groups). Default on.
pub layers: bool,
/// With `layers`: objects on layers that are off are included on PDF layers that start
/// hidden (frozen layers are never included). Default off: they aren't plotted.
/// With `layers`: objects on layers that are off are plotted on PDF layers that start
/// hidden (frozen layers are never plotted). Default off: they aren't plotted.
pub include_off_layers: bool,
/// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers
/// on the sheet becomes a link annotation.
@@ -356,23 +374,121 @@ pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) ->
if items.len() > MAX_PAGES {
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
}
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with them on.
let with_off;
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
let mut c = d.clone();
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
l.on = true;
match write_pdf(d, items, o, o.text_as_geometry) {
// A font the PDF writer can't embed after all, or one that makes it fail internally:
// plot the text as outlines instead. Outlines embed no font, so this runs at most once.
Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io),
r => r.map_err(Failure::into_io),
}
}
/// Whether a writer failure may come from embedding a font: a font the writer refused, or a
/// panic inside the writer (caught by `guard`), which a malformed font can cause.
fn font_failure(e: &WriteError) -> bool {
match e {
WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)),
WriteError::BackendPanic(_) => true,
_ => false,
}
}
/// Why writing failed: our own error, or the PDF writer's.
enum Failure {
Io(IoError),
Write(WriteError),
}
impl From<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}")),
}
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))
}
}
/// 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);
}
}
}
/// One plotted sheet, ready to be written as a page.
@@ -383,7 +499,8 @@ struct SheetPage {
list: cadcraft_render::DisplayList,
map: Map,
clip_pt: Bounds2,
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
/// candidates for PDF layers.
layers: Vec<u32>,
/// Link annotations: rectangle in points and target.
links: Vec<(Bounds2, String)>,
@@ -556,125 +673,170 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f6
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
/// A number for a content stream: short, finite, no exponent.
fn num(out: &mut String, v: f64) {
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
let mut s = format!("{v:.3}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" {
s = "0".into();
}
out.push_str(&s);
/// Largest coordinate written (points); a primitive reaching further is clipped to the plot
/// area first. The PDF writer's geometry is `f32`.
const MAX_COORD: f64 = 1.0e7;
/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range.
fn coord(p: Vec2) -> Option<(f32, f32)> {
let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
(ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32))
}
fn pt(out: &mut String, p: Vec2) {
num(out, p.x);
out.push(' ');
num(out, p.y);
/// A rectangle path between two corners (points), if it has an area.
fn rect_path(min: Vec2, max: Vec2) -> Option<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()
}
fn color(out: &mut String, c: Rgb, op: &str) {
for v in [c.0, c.1, c.2] {
num(out, f64::from(v) / 255.0);
out.push(' ');
/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its
/// own, and this is about the thinnest mark that still prints.
const HAIRLINE_DOT_PT: f32 = 0.25;
/// A circle path (points) around `(x, y)`: four cubic arcs.
fn circle_path(x: f32, y: f32, r: f32) -> Option<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));
}
out.push_str(op);
out.push('\n');
}
/// The page's marks; those of primitives on `layers` are marked as optional content
/// (`/OC /L<layer> BDC` … `EMC`).
///
/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its
/// embedded font (`/F<font>`), placed by a text matrix.
///
/// 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 c = String::new();
c.push_str("q\n1 J 1 j\n");
let mut surface = page.surface();
// The surface's y axis points down; flip it so we draw in PDF points (y up).
surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h));
// Clip to the plot area.
pt(&mut c, clip_pt.min);
c.push(' ');
num(&mut c, clip_pt.width());
c.push(' ');
num(&mut c, clip_pt.height());
c.push_str(" re W n\n");
let mut stroke: Option<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;
}
let mut drawn = Ok(());
if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) {
surface.push_clip_path(&clip, &FillRule::NonZero);
drawn = marks(&mut surface, p, groups, fonts);
surface.pop();
}
surface.pop();
surface.finish();
page.finish();
drawn
}
/// What consecutive strokes share to be painted as one path: colour, width (points) and
/// transparency (percent).
#[derive(Clone, Copy, PartialEq)]
struct StrokeStyle {
rgb: Rgb,
width: f32,
transparency: u8,
}
/// Opacity of a transparency in percent.
fn opacity(t: u8) -> NormalizedF32 {
NormalizedF32::new(f32::from(100 - t.min(cadcraft_render::MAX_TRANSPARENCY)) / 100.0).unwrap_or(NormalizedF32::ONE)
}
fn paint(c: Rgb) -> kr::paint::Paint {
kr::color::rgb::Color::new(c.0, c.1, c.2).into()
}
fn fill_path(s: &mut Surface<'_>, path: &Path, rgb: Rgb, t: u8) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(t), rule: FillRule::NonZero }));
s.draw_path(path);
}
/// Paint the strokes collected so far as one path.
fn flush(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>) {
let Some((style, pb)) = batch.take() else { return };
let Some(path) = pb.finish() else { return };
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: paint(style.rgb),
width: style.width,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
opacity: opacity(style.transparency),
..Stroke::default()
}));
s.draw_path(&path);
}
/// The PDF layer of the following marks changed from `from` to `to` (`None`: no layer): end
/// the old one's marked content and start the new one's.
fn layer_changed(s: &mut Surface<'_>, from: Option<OcgId>, to: Option<OcgId>) -> std::result::Result<(), Failure> {
if from.is_some() {
s.end_optional_content().map_err(krilla_failure)?;
}
if let Some(id) = to {
s.start_optional_content(id).map_err(krilla_failure)?;
}
Ok(())
}
fn krilla_failure(e: KrillaError) -> Failure {
Failure::Io(IoError::Format(format!("PDF export failed: {e}")))
}
/// The page's marks, clipped to the plot area by the caller. Those of primitives on a layer
/// with a PDF layer in `groups` are marked as its optional content.
///
/// Consecutive strokes with the same colour, width and transparency are one path; a
/// transparent primitive is stroked on its own (one path is painted once), so where
/// primitives overlap they build up, as in PNG plots and on screen. Block contents and
/// viewport contents share their reference's handle, so this goes by primitive, not by object.
/// Each filled primitive is one path of its triangles.
///
/// Primitives that stand for a glyph in an embeddable font (see [`fonts::Fonts`]) are written
/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one
/// em).
fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
let (list, map) = (&p.list, &p.map);
let texts = fonts::texts(list);
let white = Rgb(255, 255, 255);
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
let mut layer: Option<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();
if want != layer {
flush(&mut c, &mut pending);
if layer.is_some() {
c.push_str("EMC\n");
}
if let Some(l) = want {
let _ = writeln!(c, "/OC /L{l} BDC");
}
flush(s, &mut batch);
layer_changed(s, layer, want)?;
layer = want;
}
let rgb = p.display_rgb(white);
let raw = list.points(p);
let rgb = prim.display_rgb(white);
let raw = list.points(prim);
if raw.iter().any(|q| !q.is_finite()) {
continue;
}
// A wipeout's mask always hides what is under it.
let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
if t != alpha {
flush(&mut c, &mut pending);
let _ = writeln!(c, "/T{t} gs");
if !alphas.contains(&t) {
alphas.push(t);
}
alpha = t;
}
let w = f64::from(p.lw) * PT_PER_MM;
let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
let w = f64::from(prim.lw) * PT_PER_MM;
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
match p.kind {
match prim.kind {
Kind::Polyline | Kind::Infinite { .. } => {
let pts: Vec<Vec2> = match p.kind {
let pts: Vec<Vec2> = match prim.kind {
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
(Some(base), Some(dir)) => {
let a = map.pt(*base);
let dir_pt = map.pt(*base + *dir) - a;
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
Some((x, y)) => vec![x, y],
None => continue,
}
@@ -686,301 +848,117 @@ fn content_stream(
if pts.len() < 2 {
continue;
}
// A transparent primitive is stroked on its own (one path is painted once), so
// where primitives overlap they build up, as in PNG plots and on screen. Block
// contents and viewport contents share their reference's handle, so this goes
// by primitive, not by object.
if t > 0 {
flush(&mut c, &mut pending);
// With a vertex out of range, keep the pieces inside the plot area, widened so
// a wide stroke just outside it still shows its edge.
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) };
let style = StrokeStyle { rgb, width: w as f32, transparency: t };
if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
flush(s, &mut batch);
}
if stroke != Some(rgb) || width != Some(w) {
flush(&mut c, &mut pending);
if stroke != Some(rgb) {
color(&mut c, rgb, "RG");
stroke = Some(rgb);
for run in runs {
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<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 width != Some(w) {
num(&mut c, w);
c.push_str(" w\n");
width = Some(w);
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
for (j, (x, y)) in pts.iter().enumerate() {
if j == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
}
}
for (i, q) in pts.iter().enumerate() {
pt(&mut c, *q);
c.push_str(if i == 0 { " m\n" } else { " l\n" });
}
pending = true;
}
Kind::Tris | Kind::Mask => {
flush(&mut c, &mut pending);
flush(s, &mut batch);
// A wipeout's mask is paper white.
let rgb = if p.kind == Kind::Mask { white } else { rgb };
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
if let Some((font, t)) = text {
glyph(&mut c, &mut text_font, *font, t, map);
let rgb = if prim.kind == Kind::Mask { white } else { rgb };
if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i))
&& let Some(font) = fonts.font(&text.glyph.face)
{
glyph(s, font, text, map, rgb, t);
continue;
}
let mut any = false;
for t in raw.as_chunks::<3>().0 {
for (i, q) in t.iter().enumerate() {
pt(&mut c, map.pt(*q));
c.push_str(if i == 0 { " m\n" } else { " l\n" });
let mapped: Vec<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);
}
}
c.push_str("h\n");
any = true;
pb.close();
}
if any {
c.push_str("f\n");
if let Some(path) = pb.finish() {
fill_path(s, &path, rgb, t);
}
}
Kind::Point => {
let Some(q) = raw.first() else { continue };
flush(&mut c, &mut pending);
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
flush(s, &mut batch);
let half = (w.max(0.5)) / 2.0;
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
c.push(' ');
num(&mut c, half * 2.0);
c.push(' ');
num(&mut c, half * 2.0);
c.push_str(" re f\n");
let c = map.pt(*q);
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
fill_path(s, &path, rgb, t);
}
}
}
}
flush(&mut c, &mut pending);
if text_font.is_some() {
c.push_str("ET\n");
}
if layer.is_some() {
c.push_str("EMC\n");
}
c.push_str("Q\n");
(c, alphas)
flush(s, &mut batch);
layer_changed(s, layer, None)
}
/// The page resources' graphics states for transparencies `alphas` (percent): `/T<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;
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
let k = map.s * map.k;
let g = &t.glyph;
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
num(c, v);
c.push(' ');
let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return };
let det = a * d - b * c;
if !det.is_finite() || det == 0.0 {
return;
}
pt(c, map.pt(g.origin));
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
let text = g.ch.to_string();
let glyphs = [KrillaGlyph::new(GlyphId::new(u32::from(g.gid)), 0.0, 0.0, 0.0, 0.0, 0..text.len(), None)];
// The writer sets glyphs upright on a y-down surface: flip the em's y axis back.
s.push_transform(&Transform::from_row(a, b, -c, -d, e, f));
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(transparency), rule: FillRule::NonZero }));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, 1.0, false);
s.pop();
}
/// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII,
/// else UTF-16BE with a byte order mark (hex). At most 1000 characters.
fn pdf_string(s: &str) -> String {
let s: String = s.chars().take(1000).collect();
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
let mut out = String::from("(");
for ch in s.chars() {
if matches!(ch, '(' | ')' | '\\') {
out.push('\\');
}
out.push(ch);
}
out.push(')');
return out;
}
let mut out = String::from("<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> {
/// 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> {
let url = url.trim();
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
if url.is_empty() || url.len() > MAX_URL || matches!(scheme.as_str(), "javascript" | "vbscript" | "data") {
return None;
}
let mut out = String::from("(");
let mut out = String::with_capacity(url.len());
for b in url.bytes() {
match b {
b'(' | b')' | b'\\' => {
out.push('\\');
out.push(char::from(b));
}
0x21..=0x7E => out.push(char::from(b)),
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out.push(')');
Some(out)
}
/// A stream object body, Flate-compressed when asked.
fn stream(data: &[u8], compress: bool, extra: &str) -> Vec<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
}
+39 -150
View File
@@ -1,25 +1,22 @@
//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are
//! written as text in embedded TrueType subsets, so the text can be selected, searched and
//! copied. Each font is a `Type0` font with `Identity-H` encoding over a `CIDFontType2`
//! descendant whose CIDs are the glyph ids (`/CIDToGIDMap /Identity`), with a `ToUnicode`
//! map for copying and searching.
//! written as text in their font, which the PDF writer embeds as a subset with a `ToUnicode`
//! map, so the text can be selected, searched and copied.
//!
//! Fonts whose licence bits forbid embedding a subset, fonts without TrueType outlines, and
//! anything past the caps on fonts and font data stay as filled outlines.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use std::collections::HashMap;
use cadcraft_fonts::subset::{self, FontInfo};
use cadcraft_fonts::subset::{self, Embedding};
use cadcraft_fonts::ttf::Face;
use cadcraft_render::{DisplayList, PlotText};
use super::{Objects, num, refs, stream};
use craft_pdf::write::backend::text::Font;
/// Most embedded fonts in one file.
const MAX_FONTS: usize = 64;
/// Most embedded font data in one file (uncompressed bytes).
const MAX_FONT_BYTES: usize = 64 << 20;
/// Most font file data embedded from in one file (bytes of the font files the subsets are
/// taken from).
const MAX_FONT_BYTES: usize = 256 << 20;
/// A face's identity: its loaded file and face index.
type FaceKey = (usize, usize, u32);
@@ -28,153 +25,45 @@ fn key(f: &Face) -> FaceKey {
(f.bytes.as_ptr() as usize, f.bytes.len(), f.index)
}
/// An embedded font.
struct Font {
face: Face,
/// Glyphs used, and the character each shows.
chars: BTreeMap<u16, char>,
/// The subset file.
data: Vec<u8>,
info: FontInfo,
}
/// The fonts of a document: those that can be embedded, by face.
#[derive(Default)]
/// The fonts of a document, decided face by face as glyphs are written.
pub(super) struct Fonts {
fonts: Vec<Font>,
by_face: HashMap<FaceKey, Option<usize>>,
/// 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,
}
impl Fonts {
/// Collect the glyphs of every plotted text glyph and subset their fonts.
pub(super) fn collect<'a>(lists: impl IntoIterator<Item = &'a DisplayList>) -> Fonts {
let mut used: Vec<(Face, BTreeMap<u16, char>)> = Vec::new();
let mut index: HashMap<FaceKey, usize> = HashMap::new();
for list in lists {
for t in usable(list) {
let k = key(&t.glyph.face);
let i = match index.get(&k) {
Some(i) => *i,
None => {
index.insert(k, used.len());
used.push((t.glyph.face.clone(), BTreeMap::new()));
used.len() - 1
}
};
if let Some((_, chars)) = used.get_mut(i) {
chars.entry(t.glyph.gid).or_insert(t.glyph.ch);
}
}
pub(super) fn new(enabled: bool) -> Fonts {
Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 }
}
/// The font a glyph of `face` is written in; `None`: draw its outline.
pub(super) fn font(&mut self, face: &Face) -> Option<Font> {
if !self.enabled {
return None;
}
let mut out = Fonts::default();
let mut total = 0usize;
for (face, chars) in used {
let k = key(&face);
let font = (out.fonts.len() < MAX_FONTS)
.then(|| subset::subset(&face, &chars.keys().copied().collect::<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);
}
}
let k = key(face);
if let Some((_, f)) = self.by_face.get(&k) {
return f.clone();
}
out
}
pub(super) fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
/// The embedded font (index) a glyph is written in; `None`: draw its outline.
fn font_of(&self, t: &PlotText) -> Option<usize> {
self.by_face.get(&key(&t.glyph.face)).copied().flatten()
}
/// The plotted glyphs of `list` written as text: outline primitive → (font, glyph).
pub(super) fn texts<'a>(&self, list: &'a DisplayList) -> HashMap<u32, (usize, &'a PlotText)> {
usable(list).filter_map(|t| Some((t.prim, (self.font_of(t)?, t)))).collect()
}
/// Write the font objects; returns each font's `Type0` object, by font index.
pub(super) fn write(&self, objs: &mut Objects, compress: bool) -> Vec<usize> {
self.fonts.iter().map(|f| write_font(objs, f, compress)).collect()
}
}
/// The text glyphs of a list that stand for a primitive of it.
fn usable(list: &DisplayList) -> impl Iterator<Item = &PlotText> {
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0)
}
/// Six capital letters naming a subset (PDF subset fonts are `ABCDEF+Name`), from its glyphs.
fn subset_tag(f: &Font) -> String {
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
for b in f.info.postscript_name.bytes().chain(f.chars.keys().flat_map(|g| g.to_be_bytes())) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01B3);
}
(0..6).map(|i| char::from(b'A' + ((h >> (i * 8)) % 26) as u8)).collect()
}
fn write_font(objs: &mut Objects, f: &Font, compress: bool) -> usize {
let name = format!("{}+{}", subset_tag(f), f.info.postscript_name);
let file = objs.add(stream(&f.data, compress, &format!(" /Length1 {}", f.data.len())));
let mut bbox = String::new();
for (i, v) in f.info.bbox.iter().enumerate() {
if i > 0 {
bbox.push(' ');
let len = face.bytes.len();
let font = (self.count < MAX_FONTS && self.bytes.saturating_add(len) <= MAX_FONT_BYTES && subset::embedding(face) == Embedding::Allowed)
.then(|| Font::new(face.bytes.clone().into(), face.index))
.flatten();
if font.is_some() {
self.count += 1;
self.bytes = self.bytes.saturating_add(len);
}
num(&mut bbox, v.round());
self.by_face.insert(k, (face.clone(), font.clone()));
font
}
let mut metrics = String::new();
for (k, v) in [("Ascent", f.info.ascent), ("Descent", f.info.descent), ("CapHeight", f.info.cap_height), ("ItalicAngle", f.info.italic_angle)] {
let _ = write!(metrics, " /{k} ");
num(&mut metrics, v.round());
}
let descriptor =
objs.add(format!("<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [{bbox}]{metrics} /StemV 80 /FontFile2 {file} 0 R >>"));
let mut widths = String::new();
for g in f.chars.keys() {
let _ = write!(widths, "{g} [");
num(&mut widths, subset::advance(&f.face, *g).round());
widths.push_str("] ");
}
let cid = objs.add(format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
widths.trim_end()
));
let to_unicode = objs.add(stream(to_unicode(&f.chars).as_bytes(), compress, ""));
objs.add(format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{}] /ToUnicode {to_unicode} 0 R >>",
refs([cid])
))
}
/// A `ToUnicode` CMap from glyph ids (2-byte codes) to the characters they show.
fn to_unicode(chars: &BTreeMap<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
/// 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()
}
+102 -62
View File
@@ -4,6 +4,9 @@ use cadcraft_geom::{PolyVertex, Vec2, Vec3};
use crate::*;
#[path = "../tests/support/pdf_read.rs"]
mod pdf_read;
fn sample() -> Drawing {
let mut d = Drawing::new_imperial();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
@@ -418,50 +421,22 @@ fn hostile_export_window_is_an_error() {
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
/// content.
fn check_pdf(bytes: &[u8]) -> String {
assert!(bytes.starts_with(b"%PDF-1.4"));
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
assert!(tail.trim_end().ends_with("%%EOF"));
let sx = rfind(b"startxref\n").unwrap();
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
assert!(bytes[xref_off..].starts_with(b"xref\n"));
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
let mut lines = xref.lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
assert_eq!(count, objs + 1, "xref size = objects + free entry");
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
let entries: Vec<&str> = lines.take(count).collect();
assert!(entries[0].starts_with("0000000000 65535 f"));
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
}
assert!(xref.contains(&format!("/Size {count}")));
// The content stream's /Length matches its data.
let sp = find(b"/Length ").unwrap();
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = find(b"stream\n").unwrap() + 7;
assert!(bytes[start + len..].starts_with(b"\nendstream"));
let compressed = find(b"/FlateDecode").is_some();
let data = &bytes[start..start + len];
if compressed {
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
} else {
String::from_utf8(data.to_vec()).unwrap()
}
assert!(String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).trim_end().ends_with("%%EOF"));
let doc = pdf_read::read(bytes);
assert!(!doc.pages.is_empty());
doc.content().to_string()
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
let text = String::from_utf8_lossy(bytes).to_string();
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
(v[0], v[1])
pdf_read::read(bytes).media()
}
/// The line widths (`w`) the content sets.
fn widths(content: &str) -> Vec<f64> {
pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect()
}
#[test]
@@ -471,16 +446,17 @@ fn pdf_model_fitted_to_sheet() {
let content = check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
// Walls layer is red; colour 7 prints black.
assert!(content.contains("1 0 0 RG"));
assert!(content.contains("0 0 0 RG"));
// Uncompressed output is plain text and also valid.
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
let c2 = check_pdf(&raw);
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert!(!pdf_read::read(&raw).pages[0].compressed);
assert!(pdf_read::read(&bytes).pages[0].compressed);
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(c2.contains("re W n"));
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
@@ -513,11 +489,12 @@ fn pdf_layout_one_to_one_with_viewport() {
// ANSI A landscape (inches → points).
assert_eq!(media_box(&bytes), (792.0, 612.0));
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
let corner = |o: &pdf_read::Op| matches!(o.op.as_str(), "m" | "l") && (o.nums[0] - 72.0).abs() < 1e-6 && (o.nums[1] - 72.0).abs() < 1e-6;
assert!(pdf_read::drawn(&content).iter().any(corner), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(content.contains("0.709 w"));
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(check_pdf(&off).contains("0 w"));
assert!(widths(&check_pdf(&off)).contains(&0.0));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
@@ -2438,15 +2415,16 @@ fn pdf_plots_transparency_only_when_asked() {
};
let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap();
let content = check_pdf(&on);
let text = String::from_utf8_lossy(&on).to_string();
assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}");
// One graphics state: fill alpha 0.5 (strokes stay opaque).
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
// The line is opaque, the solid at half alpha.
assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}");
let ops = pdf_read::ops(&content);
assert!(ops.windows(2).any(|w| w[0].op == "gs" && w[1].op == "rg" && w[1].nums == [1.0, 0.0, 0.0]), "{content}");
assert_eq!(pdf_read::count(&content, "gs"), 1, "{content}");
// Off (the default, and explicitly): no graphics states, and the same bytes either way.
let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap();
assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap());
let off_text = String::from_utf8_lossy(&off).to_string();
assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n"));
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
// On, but nothing transparent: the same bytes as off.
let opaque = transparent_over_line(0);
assert_eq!(
@@ -2465,13 +2443,13 @@ fn layout_page_setup_plot_transparency_is_used() {
let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() };
d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap();
let raw = serde_json::json!({"compress": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState"));
let alphas = |d: &Drawing, o: &serde_json::Value| pdf_read::read(&plot(d, &paper, o).unwrap()).pages[0].alphas.clone();
assert!(alphas(&d, &raw).is_empty());
d.layouts[0].page.plot_transparency = true;
let text = String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).to_string();
assert!(text.contains("/T90 << /Type /ExtGState /CA 0.1 /ca 0.1 >>"), "{text}");
let on = alphas(&d, &raw);
assert!(on.iter().any(|(_, f)| (f - 0.1).abs() < 1e-6), "the faint solid fills at alpha 0.1: {on:?}");
// The plot's own option wins over the page setup.
let off = serde_json::json!({"compress": false, "plotTransparency": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState"));
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
}
#[test]
@@ -2526,11 +2504,14 @@ fn pdf_strokes_overlapping_transparent_objects_one_by_one() {
};
// 50 %: each line is its own stroke, so they build up where they cross.
let glass = content(&crossing(50), true);
assert!(glass.contains("/T50 gs"), "{glass}");
assert_eq!(glass.matches("S\n").count(), 2, "{glass}");
assert!(pdf_read::count(&glass, "gs") > 0, "{glass}");
let page =
pdf_read::read(&plot(&crossing(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()).pages.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&glass, "S"), 2, "{glass}");
// Opaque: one path as before, whether the option is on or off.
let opaque = content(&crossing(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing(0), false));
// Transparency off: the transparent layer plots as before too.
assert_eq!(content(&crossing(50), false), opaque);
@@ -2567,11 +2548,15 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() {
check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap())
};
let half = content(&crossing_block(50), true);
assert!(half.contains("/T50 gs"), "{half}");
assert_eq!(half.matches("S\n").count(), 2, "{half}");
assert!(pdf_read::count(&half, "gs") > 0, "{half}");
let page = pdf_read::read(&plot(&crossing_block(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap())
.pages
.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&half, "S"), 2, "{half}");
// Opaque: one path, the same with the option on or off.
let opaque = content(&crossing_block(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing_block(0), false));
assert_eq!(content(&crossing_block(50), false), opaque);
}
@@ -2616,6 +2601,61 @@ fn drawing_without_insunits_stays_unitless() {
assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into()));
}
#[test]
fn pdf_clips_a_primitive_beyond_the_coordinate_range() {
let mut d = Drawing::new_metric();
let paper = Space::Paper("Layout1".into());
let v = |x: f64, y: f64| Vec3::new(x, y, 0.0);
// 4e7 mm at 1:1 is about 1.1e8 pt, past the range the writer's f32 geometry takes.
d.add(&paper, Common::default(), EntityKind::Line(Line { a: v(10.0, 20.0), b: v(4.0e7, 20.0) })).unwrap();
d.add(&paper, Common::default(), EntityKind::Solid(Solid { corners: [v(10.0, 50.0), v(4.0e7, 50.0), v(10.0, 60.0), v(10.0, 60.0)] })).unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({"fit": false, "scale": 1.0, "compress": false})).unwrap();
let content = check_pdf(&bytes);
let clip = pdf_read::clip_rect(&content).unwrap();
// The x extent of what each paint operator paints.
let (mut xs, mut painted) = (Vec::new(), Vec::new());
for o in pdf_read::drawn(&content) {
match o.op.as_str() {
"m" | "l" => xs.push(o.nums[0]),
"S" | "f" => painted.push((o.op, xs.drain(..).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x))))),
_ => {}
}
}
let (stroke, fill) = (painted.iter().find(|p| p.0 == "S"), painted.iter().find(|p| p.0 == "f"));
// Both run from inside the plot area to its right edge: the stroke to just past it.
let (_, (lo, hi)) = stroke.expect("the line is drawn");
assert!(*lo < clip[2] && *hi > clip[2] && *hi < clip[2] + 5.0, "line from {lo} to {hi}, clip {clip:?}");
let (_, (lo, hi)) = fill.expect("the solid is filled");
assert!(*lo < clip[2] && (*hi - clip[2]).abs() < 1e-3, "fill from {lo} to {hi}, clip {clip:?}");
}
#[test]
fn pdf_plots_a_zero_length_line_as_a_dot() {
let mut d = Drawing::new_metric();
let red = |lw| Common { color: Color::Index(1), lineweight: lw, ..Common::default() };
// Points at 1:1 on A4, so the dot sits away from the clip edges.
let at = Vec3::new(50.0, 50.0, 0.0);
d.add(&Space::Model, red(Lineweight::Mm100(100)), EntityKind::Line(Line { a: at, b: at })).unwrap();
// The x extent of what the content fills in red: the dot's diameter.
let red_fill_width = |o: serde_json::Value| {
let content = check_pdf(&plot(&d, &Space::Model, &o).unwrap());
let ops = pdf_read::ops(&content);
let start = ops.iter().position(|o| o.op == "rg" && o.nums == [1.0, 0.0, 0.0]).expect("a red fill");
let end = start + ops[start..].iter().position(|o| o.op == "f").expect("filled");
assert!(ops[start..end].iter().filter(|o| o.op == "c").count() == 4, "{content}");
let xs: Vec<f64> =
ops[start..end].iter().filter(|o| matches!(o.op.as_str(), "m" | "c")).flat_map(|o| o.nums.iter().step_by(2).copied()).collect();
xs.iter().copied().fold(f64::MIN, f64::max) - xs.iter().copied().fold(f64::MAX, f64::min)
};
let opts = |lineweights: bool| serde_json::json!({"paper": "A4", "fit": false, "scale": 1.0, "compress": false, "lineweights": lineweights});
// 1 mm lineweight: a dot 1 mm across.
let w = red_fill_width(opts(true));
assert!((w - 72.0 / 25.4).abs() < 0.01, "{w}");
// A hairline still leaves a mark.
let w = red_fill_width(opts(false));
assert!((w - 0.25).abs() < 0.01, "{w}");
}
/// DXF text as (code, value) pairs.
fn dxf_pairs(text: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = text.lines().collect();
+2 -2
View File
@@ -44,9 +44,9 @@ fn a_millimetre_layout_plots_in_millimetres_whatever_measurement_says() {
// The frame lands 10 mm (28.35 pt) inside the page, not 10 inches.
let bytes = pdf(&d, &Space::Paper("SHEET".into()), &PdfOptions { compress: false, ..PdfOptions::default() }).unwrap();
let text = String::from_utf8_lossy(&bytes);
let doc = crate::pdf_read::read(&bytes);
let xs: Vec<f64> =
text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
assert!(!xs.is_empty(), "no path in the PDF");
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
assert!((lo - 28.35).abs() < 0.1 && (hi - 813.54).abs() < 0.1, "frame from {lo} to {hi} pt");
+2
View File
@@ -45,6 +45,8 @@ mod paperview_marker;
mod passthrough_image_roundtrip;
mod pdf_followups;
mod pdf_layers;
#[path = "../support/pdf_read.rs"]
mod pdf_read;
mod plot_layout_scale;
mod plot_model_png_scale;
mod plot_styles;
+18 -16
View File
@@ -12,18 +12,22 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
d.add(&Space::Model, Common::default(), k).unwrap();
}
fn pdf_text(d: &Drawing, opts: Value) -> String {
fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
m.extend(e.clone());
}
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap())
}
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
fn clip(pdf: &str) -> Vec<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()
/// 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()
}
#[test]
@@ -32,17 +36,17 @@ fn model_space_pdf_uses_the_device_margins() {
line(&mut d, (0.0, 0.0), (100.0, 50.0));
let mm = 72.0 / 25.4;
// No device: the page setup's printable area, as before.
let plain = clip(&pdf_text(&d, json!({})));
let plain = clip(&pdf_doc(&d, json!({})));
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
// A device paper with 20 mm margins: the printable area starts 20 mm in.
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
let r = clip(&pdf_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
assert_eq!(r.len(), 4, "{r:?}");
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
// Hostile margins are ignored.
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
assert_eq!(clip(&pdf_doc(&d, json!({"margins": bad}))), plain, "{bad}");
}
}
@@ -107,13 +111,11 @@ fn pe_url_hyperlinks_become_link_annotations() {
let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]);
assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links");
let read = read_dxf(text.as_bytes()).unwrap();
let pdf = pdf_text(&read, json!({}));
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}");
assert!(pdf.contains("https://example.com/plan"));
assert!(!pdf.contains("javascript") && !pdf.contains("data:text"));
let pdf = uris(&pdf_doc(&read, json!({})));
assert_eq!(pdf, ["https://example.com/plan"], "only the web link");
// Turned off, and given explicitly for the same object (the explicit one wins).
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty());
let first = read.model.iter().next().unwrap().handle.hex();
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
let own = uris(&pdf_doc(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]})));
assert!(own.contains(&"https://example.org/own".to_string()) && !own.contains(&"https://example.com/plan".to_string()), "{own:?}");
}
+131 -118
View File
@@ -1,6 +1,6 @@
//! PDF export with layers, several sheets and hyperlinks (issue #560): drawing layers become
//! optional content groups, `layouts` plots several sheets into one file with page labels, and
//! linked entities get link annotations. The tests parse the written objects.
//! linked entities get link annotations. The tests read the written file back.
use cadcraft_color::Color;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport};
@@ -9,57 +9,19 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets};
use cadcraft_io::{PdfOptions, plot};
use serde_json::json;
/// The objects of a PDF by number, after checking the cross-reference table points at them.
fn objects(bytes: &[u8]) -> Vec<(usize, String)> {
assert!(bytes.starts_with(b"%PDF-1."));
// Byte offsets stay valid: non-ASCII bytes (the binary comment) become `?`.
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let sx = text.rfind("startxref\n").expect("startxref");
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
assert!(text[xref..].starts_with("xref\n"));
let mut lines = text[xref..].lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let entries: Vec<&str> = lines.take(count).collect();
assert_eq!(entries.len(), count);
let mut out = Vec::new();
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
let head = format!("{i} 0 obj\n");
assert!(bytes[off..].starts_with(head.as_bytes()), "object {i} offset");
let body = &text[off + head.len()..];
let end = body.find("\nendobj\n").expect("endobj");
out.push((i, body[..end].to_string()));
}
assert!(text.contains(&format!("/Size {count}")));
out
use crate::pdf_read::{self, Doc};
fn read(bytes: &[u8]) -> Doc {
pdf_read::read(bytes)
}
fn object(objs: &[(usize, String)], n: usize) -> &str {
&objs.iter().find(|(i, _)| *i == n).unwrap().1
fn ocg_names(doc: &Doc) -> Vec<String> {
doc.ocgs.iter().map(|g| g.name.clone()).collect()
}
/// The object a `/Key n 0 R` entry of `dict` refers to.
fn reference(dict: &str, key: &str) -> usize {
let i = dict.find(&format!("{key} ")).unwrap_or_else(|| panic!("{key} in {dict}")) + key.len() + 1;
dict[i..].split_whitespace().next().unwrap().parse().unwrap()
}
/// Uncompressed content streams in page order.
fn contents(objs: &[(usize, String)]) -> Vec<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()
/// 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()
}
fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle {
@@ -109,45 +71,66 @@ fn raw(extra: serde_json::Value) -> serde_json::Value {
#[test]
fn drawing_layers_become_pdf_layers() {
let d = layered();
let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
// Plotted layers only, in drawing order of first use; block contents on layer 0 are on
// the block reference's layer.
assert_eq!(names, ["(0)", "(Walls)", "(Doors)"]);
let cat = catalog(&objs);
assert!(cat.contains("/Version /1.5") && cat.contains("/OCProperties") && cat.contains("/OFF []"), "{cat}");
// Every OCG is listed and switchable.
let ocgs: Vec<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 block reference's layer. None starts hidden.
assert_eq!(ocg_names(&doc), ["0", "Walls", "Doors"]);
assert!(doc.ocgs.iter().all(|g| !g.off));
// One sheet: the viewer opens with its layer panel.
assert_eq!(doc.page_mode.as_deref(), Some("UseOC"));
// The page maps its marks to the groups, and the marked sections are balanced.
// Marks are named after the layer's place in the layer table (0, Defpoints, Walls, …).
let page = &pages(&objs)[0];
for i in [0, 2, 5] {
let id = reference(page, &format!("/L{i}"));
assert!(ocgs.contains(&id));
let page = &doc.pages[0];
let groups: Vec<&str> = page.properties.iter().map(|(_, g)| g.as_str()).collect();
for n in ["0", "Walls", "Doors"] {
assert!(groups.contains(&n), "{n} in {groups:?}");
}
let c = &contents(&objs)[0];
assert_eq!(c.matches(" BDC\n").count(), c.matches("EMC\n").count());
assert!(c.contains("/OC /L2 BDC\n1 0 0 RG"), "the red wall line is on Walls: {c}");
let (q, bdc, emc) = (c.find("re W n").unwrap(), c.find("BDC").unwrap(), c.rfind("EMC").unwrap());
assert!(q < bdc && emc < c.rfind("Q\n").unwrap(), "marked content inside the clip's q/Q");
let c = &page.content;
assert_eq!(pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC"));
for m in marks(c) {
assert!(page.properties.iter().any(|(k, _)| *k == m), "mark {m} is a page property");
}
// The red wall line is on Walls.
let walls = page.properties.iter().find(|(_, g)| g == "Walls").map(|(k, _)| k.clone()).unwrap();
let ops = pdf_read::ops(c);
let at = ops.iter().position(|o| o.op == "BDC" && o.names.get(1) == Some(&walls)).unwrap();
let next = ops[at..].iter().find(|o| o.op == "RG").unwrap();
assert_eq!(next.nums, [1.0, 0.0, 0.0], "{c}");
// Marked content inside the clip's q/Q.
let first_bdc = ops.iter().position(|o| o.op == "BDC").unwrap();
let last_emc = ops.iter().rposition(|o| o.op == "EMC").unwrap();
let clip = ops.iter().position(|o| o.op == "W").unwrap();
let last_q = ops.iter().rposition(|o| o.op == "Q").unwrap();
assert!(clip < first_bdc && last_emc < last_q, "marked content inside the clip's q/Q");
}
#[test]
fn layers_that_are_off_are_included_hidden_on_request() {
let d = layered();
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
let names = ocg_names(&objs);
assert!(names.contains(&"(Notes)".to_string()) && !names.contains(&"(Hidden)".to_string()), "{names:?}");
let notes = objs.iter().find(|(_, b)| b.contains("/Name (Notes)")).unwrap().0;
let cat = catalog(&objs);
assert!(cat.contains(&format!("/OFF [{notes} 0 R]")), "{cat}");
// Off: neither the layer nor its objects; without layers, no optional content at all.
let flat = objects(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
assert!(ocg_names(&flat).is_empty() && !catalog(&flat).contains("/OCProperties"));
assert!(!contents(&flat)[0].contains("BDC"));
let doc = read(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
let names = ocg_names(&doc);
assert!(names.contains(&"Notes".to_string()) && !names.contains(&"Hidden".to_string()), "{names:?}");
let off: Vec<&str> = doc.ocgs.iter().filter(|g| g.off).map(|g| g.name.as_str()).collect();
assert_eq!(off, ["Notes"]);
// Without layers, no optional content at all.
let flat = read(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
assert!(flat.ocgs.is_empty() && flat.page_mode.is_none());
assert_eq!(pdf_read::count(flat.content(), "BDC"), 0);
}
#[test]
fn layers_that_are_off_are_plotted_only_on_request() {
let d = layered();
let strokes = |o: serde_json::Value| {
let doc = read(&plot(&d, &Space::Model, &raw(o)).unwrap());
pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m").count()
};
// By default Notes (off) and Hidden (frozen) aren't drawn: the four lines less those two,
// plus the door.
let plain = strokes(json!({}));
assert_eq!(plain, 3, "lines on 0 and Walls, and the door leaf");
// `offLayers` adds Notes (on a hidden PDF layer), never the frozen layer; it needs PDF layers.
assert_eq!(strokes(json!({"offLayers": true})), 4);
assert_eq!(strokes(json!({"layers": false, "offLayers": true})), plain);
}
#[test]
@@ -169,43 +152,50 @@ fn viewport_contents_keep_their_layers() {
};
d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));
let objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap()));
// The viewport's frozen layer is left out; the model layers seen through it are kept.
for n in ["(Viewports)", "(0)", "(Doors)", "(Title)"] {
for n in ["Viewports", "0", "Doors", "Title"] {
assert!(names.contains(&n.to_string()), "{n} in {names:?}");
}
assert!(!names.contains(&"(Walls)".to_string()));
assert!(!names.contains(&"Walls".to_string()));
}
#[test]
fn several_sheets_in_one_file_with_page_labels() {
let mut d = layered();
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
let bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap();
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
// "all": model (it has objects), then every layout in tab order.
let all = objects(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
assert!(catalog(&all).contains("[0 << /P (Model) >> 1 << /P (Layout1) >> 2 << /P (Layout2) >>]"), "{}", catalog(&all));
let objs = objects(&bytes);
let tree = objs.iter().find(|(_, b)| b.starts_with("<< /Type /Pages")).unwrap().1.clone();
assert!(tree.contains("/Count 2"), "{tree}");
assert_eq!(pages(&objs).len(), 2);
let cat = catalog(&objs);
assert!(cat.contains("/PageLabels << /Nums [0 << /P (Model) >> 1 << /P (Layout1) >>] >>"), "{cat}");
// One OCG per layer for the whole file, shared by the pages.
let names = ocg_names(&objs);
assert_eq!(names.iter().filter(|n| *n == "(Walls)").count(), 1);
let walls = objs.iter().find(|(_, b)| b.contains("/Name (Walls)")).unwrap().0;
for p in pages(&objs) {
assert!(p.contains(&format!(" {walls} 0 R")), "{p}");
}
let all = read(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
assert_eq!(all.labels, ["Model", "Layout1", "Layout2"]);
assert_eq!(doc.pages.len(), 2);
assert_eq!(doc.labels, ["Model", "Layout1"]);
// Each page has its own sheet: model on A4 landscape, the layout on its page setup.
let c = contents(&objs);
assert!(c[0].contains("1 0 0 RG") && c[1].contains("1 0 0 RG"));
for p in &doc.pages {
assert!(pdf_read::ops(&p.content).iter().any(|o| o.op == "RG" && o.nums == [1.0, 0.0, 0.0]), "the red wall line: {}", p.content);
}
let size = |p: &pdf_read::Page| (p.media[2] - p.media[0], p.media[3] - p.media[1]);
assert!((size(&doc.pages[0]).0 - 841.89).abs() < 0.01 && (size(&doc.pages[0]).1 - 595.276).abs() < 0.01);
// The library call does the same.
let o = PdfOptions { compress: false, ..PdfOptions::default() };
let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>"));
let doc2 = read(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert_eq!(doc2.labels, ["Layout1", "Model"]);
// Title: the first page's label unless given.
assert_eq!(doc2.title.as_deref(), Some("Layout1"));
assert_eq!((doc2.creator.as_deref(), doc2.producer.as_deref()), (Some("CADCraft"), Some("CADCraft")));
}
#[test]
fn several_sheets_share_one_pdf_layer_per_drawing_layer() {
let mut d = layered();
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
assert_eq!(ocg_names(&doc).iter().filter(|n| *n == "Walls").count(), 1);
for p in &doc.pages {
assert!(p.properties.iter().any(|(_, g)| g == "Walls"));
}
// Several sheets: no layer panel forced open.
assert!(doc.page_mode.is_none());
}
#[test]
@@ -230,28 +220,51 @@ fn hyperlinks_become_link_annotations() {
{"handle": h.hex(), "url": "https://example.com/ü"},
{"url": "https://example.com/no-handle"},
]);
let objs = objects(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
let annots: Vec<&String> = objs.iter().map(|(_, b)| b).filter(|b| b.contains("/Subtype /Link")).collect();
assert_eq!(annots.len(), 2, "{annots:?}");
assert!(annots[0].contains("/A << /S /URI /URI (https://example.com/a\\(1\\)) >>"), "{}", annots[0]);
assert!(annots[1].contains("(https://example.com/%C3%BC)"), "{}", annots[1]);
let page = &pages(&objs)[0];
assert!(page.contains("/Annots ["));
let doc = read(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
let annots = &doc.pages[0].links;
assert_eq!(annots.len(), 2);
assert_eq!(annots[0].uri, "https://example.com/a(1)");
assert_eq!(annots[1].uri, "https://example.com/%C3%BC");
// The rectangle is inside the A4 landscape sheet and wide like the line.
let r = annots[0].split("/Rect [").nth(1).unwrap().split(']').next().unwrap();
let v: Vec<f64> = r.split_whitespace().map(|x| x.parse().unwrap()).collect();
let v = annots[0].rect;
assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}");
}
#[test]
fn unusual_layer_names_and_titles_are_escaped() {
fn unusual_names() -> Drawing {
let mut d = Drawing::new_metric();
for name in ["a(b)\\c", "Wände", "日本"] {
d.layers.push(Layer { name: name.into(), ..Layer::default() });
add_line(&mut d, &Space::Model, name, (0.0, 0.0), (10.0, 10.0));
}
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"title": "Plan ü"}))).unwrap());
let names = ocg_names(&objs);
assert_eq!(names, ["(a\\(b\\)\\\\c)", "<FEFF005700E4006E00640065>", "<FEFF65E5672C>"]);
assert!(objs.iter().any(|(_, b)| b.contains("/Title <FEFF0050006C0061006E002000FC>")));
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));
}
+4 -3
View File
@@ -14,9 +14,10 @@ fn plotted_mm(opts: Value) -> f64 {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
let xs: Vec<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}");
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());
(xs[1] - xs[0]).abs() * 25.4 / 72.0
}
+2 -2
View File
@@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing {
d
}
/// The uncompressed PDF of Layout1 with extra plot options.
/// The page content of Layout1's PDF with extra plot options.
fn pdf_text(d: &Drawing, extra: serde_json::Value) -> String {
let mut opts = json!({"compress": false});
if let (Some(o), Some(e)) = (opts.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(d, &Space::Paper("Layout1".into()), &opts).unwrap();
String::from_utf8_lossy(&bytes).into_owned()
crate::pdf_read::read(&bytes).content().to_string()
}
#[test]
+91 -77
View File
@@ -10,6 +10,11 @@ use cadcraft_geom::{Vec2, Vec3};
use cadcraft_io::plot;
use serde_json::json;
#[path = "support/pdf_read.rs"]
mod pdf_read;
use pdf_read::Doc;
// ---------- a minimal TrueType font: .notdef, "A" (triangle), "B" (square) ----------
fn be16(v: &mut Vec<u8>, x: u16) {
@@ -46,8 +51,9 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec<u8> {
g
}
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`.
fn test_font(fs_type: u16) -> Vec<u8> {
/// 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> {
let glyphs = [Vec::new(), simple_glyph(&[(50, 0), (550, 0), (300, 700)]), simple_glyph(&[(50, 0), (550, 0), (550, 700), (50, 700)])];
let mut glyf = Vec::new();
let mut loca = Vec::new();
@@ -82,7 +88,7 @@ fn test_font(fs_type: u16) -> Vec<u8> {
be16(&mut maxp, v);
}
let mut hmtx = Vec::new();
for _ in 0..3 {
for _ in 0..metrics {
be16(&mut hmtx, 600);
be16(&mut hmtx, 50);
}
@@ -142,12 +148,14 @@ fn test_font(fs_type: u16) -> Vec<u8> {
out
}
/// Register the test fonts once: an embeddable one and one with a restricted licence.
/// Register the test fonts once: an embeddable one, one with a restricted licence, and one
/// the PDF writer accepts but can't subset (its `hmtx` is short).
fn fonts() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002));
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0, 3));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002, 3));
cadcraft_fonts::ttf::register("cadcraft-pdf-short-hmtx.ttf", test_font(0, 1));
});
}
@@ -178,77 +186,72 @@ fn drawing() -> Drawing {
let mut d = Drawing::new_metric();
style(&mut d, "Real", "cadcraft-pdf-test.ttf");
style(&mut d, "Locked", "cadcraft-pdf-restricted.ttf");
style(&mut d, "Short", "cadcraft-pdf-short-hmtx.ttf");
d
}
/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`).
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String {
/// The uncompressed PDF, read back.
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) {
m.extend(e.clone());
}
let bytes = plot(d, space, &o).unwrap();
check_xref(&bytes);
bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()
pdf_read::read(&plot(d, space, &o).unwrap())
}
/// The cross-reference table points at every object.
fn check_xref(bytes: &[u8]) {
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let sx = text.rfind("startxref\n").unwrap();
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
let mut lines = text[xref..].lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
for (i, e) in lines.take(count).enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(text[off..].starts_with(&format!("{i} 0 obj\n")), "object {i}");
}
/// The glyph matrices (`a b c d e f`: em axes and origin on the page) of the text, in order.
fn matrices(doc: &Doc) -> Vec<[f64; 6]> {
pdf_read::text_matrices(doc.content())
}
/// The text matrices (`a b c d e f Tm`) of the content, in order.
fn matrices(pdf: &str) -> Vec<[f64; 6]> {
pdf.lines()
.filter(|l| l.ends_with(" Tj") && l.contains(" Tm "))
.map(|l| {
let v: Vec<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]]
})
/// 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<_>>())
.collect()
}
/// The embedded font file (FontFile2 stream data).
fn font_file(pdf: &str, bytes_of: &[u8]) -> Vec<u8> {
let i = pdf.find("/Length1 ").expect("a FontFile2 stream");
let head = &pdf[..i];
let len_at = head.rfind("/Length ").unwrap() + 8;
let len: usize = pdf[len_at..].split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = pdf[i..].find("stream\n").unwrap() + i + 7;
bytes_of[start..start + len].to_vec()
/// Filled paths drawn (glyph outlines are fills).
fn fills(doc: &Doc) -> usize {
pdf_read::count(doc.content(), "f")
}
/// Embedded font files.
fn font_files(doc: &Doc) -> usize {
doc.pages.iter().flat_map(|p| &p.fonts).filter(|f| f.font_file.is_some()).count()
}
#[test]
fn truetype_text_is_embedded_as_text() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
let doc = pdf(&d, &Space::Model, json!({}));
let page = &doc.pages[0];
// A Type0 font over a CIDFontType2 subset with a ToUnicode map.
assert!(p.contains("/Subtype /Type0") && p.contains("/Encoding /Identity-H") && p.contains("/Subtype /CIDFontType2"), "{p}");
assert!(p.contains("/CIDToGIDMap /Identity") && p.contains("/FontFile2 ") && p.contains("/ToUnicode "));
let base = p.split("/BaseFont /").nth(1).unwrap().split_whitespace().next().unwrap();
assert!(base.len() == 11 && base.as_bytes()[6] == b'+' && base[..6].chars().all(|c| c.is_ascii_uppercase()), "{base}");
assert!(p.contains("<0001> <0041>") && p.contains("<0002> <0042>"), "ToUnicode maps glyphs to A and B");
assert!(p.contains("/W [1 [600] 2 [600]]"), "widths");
// The page uses the font; four glyphs written as text, in a text object.
assert!(p.contains("/Font << /F0 "));
let tj: Vec<&str> = p.lines().filter(|l| l.ends_with(" Tj")).map(|l| l.rsplit(' ').nth(1).unwrap()).collect();
assert_eq!(tj, ["<0001>", "<0002>", "<0002>", "<0001>"]);
assert!(p.contains("BT\n/F0 1 Tf\n") && p.contains("ET\n"));
assert_eq!(p.matches("BT\n").count(), p.matches("ET\n").count());
assert_eq!(page.fonts.len(), 1);
let f = &page.fonts[0];
assert_eq!((f.subtype.as_str(), f.encoding.as_deref(), f.descendant.as_deref()), ("Type0", Some("Identity-H"), Some("CIDFontType2")));
assert!(f.cid_to_gid.as_deref() == Some("Identity") && f.font_file.is_some() && f.to_unicode.is_some());
let base = f.base_font.as_bytes();
assert!(base.len() > 7 && base[6] == b'+' && base[..6].iter().all(u8::is_ascii_uppercase), "{}", f.base_font);
// Four glyphs written as text: A B B A.
let c = codes(&doc);
assert_eq!(c.len(), 4, "{c:?}");
assert!(c[0] == c[3] && c[1] == c[2] && c[0] != c[1], "{c:?}");
let map = pdf_read::to_unicode_map(f.to_unicode.as_deref().unwrap());
assert!(map.contains(&(c[0], "A".into())) && map.contains(&(c[1], "B".into())), "ToUnicode maps the glyphs to A and B: {map:?}");
for code in [c[0], c[1]] {
assert!(f.widths.contains(&(code, 600.0)), "width of {code}: {:?}", f.widths);
}
// Text objects are balanced, at font size 1.
assert_eq!(pdf_read::count(&page.content, "BT"), pdf_read::count(&page.content, "ET"));
assert!(pdf_read::ops(&page.content).iter().filter(|o| o.op == "Tf").all(|o| o.nums == [1.0]));
// No filled outlines left for them.
assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}");
assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content);
// The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be.
let m = matrices(&p);
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0));
let m = matrices(&doc);
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0), "{m:?}");
let step = m[1][4] - m[0][4];
assert!((step - 0.6 * m[0][0]).abs() < 0.01, "advance {step} for em {}", m[0][0]);
assert!(m.windows(2).all(|w| (w[0][5] - w[1][5]).abs() < 1e-3), "one baseline");
@@ -258,21 +261,12 @@ fn truetype_text_is_embedded_as_text() {
fn the_subset_is_a_valid_truetype_font() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0);
let bytes = plot(&d, &Space::Model, &json!({"compress": false})).unwrap();
let p: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let file = font_file(&p, &bytes);
assert!(p.contains(&format!("/Length1 {}", file.len())));
let doc = pdf(&d, &Space::Model, json!({}));
let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file");
let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses");
assert_eq!(cadcraft_fonts::subset::embedding(&face), cadcraft_fonts::subset::Embedding::Allowed);
// Glyph A kept, B (unused) emptied: the subset is smaller than the font.
assert!(file.len() < test_font(0).len());
// Whole-file checksum adjusted.
let sum = file.chunks(4).fold(0u32, |s, c| {
let mut w = [0u8; 4];
w[..c.len()].copy_from_slice(c);
s.wrapping_add(u32::from_be_bytes(w))
});
assert_eq!(sum, 0xB1B0_AFBA);
// Glyph A kept, B (unused) left out: the subset is smaller than the font.
assert!(file.len() < test_font(0, 3).len());
}
#[test]
@@ -283,7 +277,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0);
text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians());
let m = matrices(&pdf(&d, &Space::Model, json!({})));
assert_eq!(m.len(), 3);
assert_eq!(m.len(), 3, "{m:?}");
// Rotated a quarter turn: the baseline points up, "up" points left.
let [a, b, c, dd, _, _] = m[0];
assert!(a.abs() < 1e-3 && b > 0.0 && c < 0.0 && dd.abs() < 1e-3, "{:?}", m[0]);
@@ -297,21 +291,29 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
assert!(em_mm > 0.0);
}
#[test]
fn a_font_the_writer_cannot_subset_plots_as_outlines() {
let mut d = drawing();
text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content());
}
#[test]
fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() {
let mut d = drawing();
text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines");
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({"text": "geometry"}));
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry");
// The stroke font is always geometry.
let mut d = drawing();
text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n"));
assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0);
}
#[test]
@@ -337,14 +339,26 @@ fn text_seen_through_a_viewport_and_on_layers() {
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0);
let p = pdf(&d, &paper, json!({}));
let tj = p.lines().filter(|l| l.ends_with(" Tj")).count();
// "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The
// glyph the edge cuts stays a (clipped) outline.
assert_eq!(tj, 4, "{p}");
assert!(p.contains("h\nf\n"), "the cut glyph is an outline");
// Text sits in its layer's marked content.
let first_bt = p.find("BT\n").unwrap();
assert!(p[..first_bt].contains("/OC /L0 BDC"));
assert_eq!(pdf_read::count(p.content(), "Tj"), 4, "{}", p.content());
assert!(fills(&p) > 0, "the cut glyph is an outline");
// Text seen through the viewport sits in its layer's marked content.
let ops = pdf_read::ops(p.content());
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
}
#[test]
fn text_sits_in_its_layers_marked_content() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
let ops = pdf_read::ops(p.content());
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
}
#[test]
+518
View File
@@ -0,0 +1,518 @@
//! Reads a plotted PDF back for tests, with craft-pdf's reader: pages, media boxes, decoded
//! content, transparency graphics states, link annotations, fonts, page labels and document
//! information.
#![allow(dead_code)]
use std::ops::Deref;
use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfString};
/// What a reader sees in a PDF.
pub struct Doc {
pub pages: Vec<Page>,
/// 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
}