Files
cadcraft/crates/io/src/pdf.rs
T

1105 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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
//! viewer can switch on and off, and entity hyperlinks become link annotations.
use std::fmt::Write as _;
use cadcraft_color::Rgb;
use cadcraft_doc::{Drawing, Handle, PageSetup, Space};
use cadcraft_geom::{Bounds2, Vec2};
use cadcraft_render::{DPrim, 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;
/// PDF points per millimetre.
const PT_PER_MM: f64 = 72.0 / 25.4;
/// Largest sheet side we accept, in millimetres (PDF viewers cap pages at 200 inches anyway).
const MAX_SHEET_MM: f64 = 5080.0;
/// Most sheets (pages) in one file.
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).
#[derive(Clone, Debug)]
pub struct PdfOptions {
/// Plot style table name, replacing the page setup's (`"None"` plots without one).
pub plot_style_table: Option<String>,
/// Paper name from [`cadcraft_render::PAPER_SIZES`] (`"A4"`, `"Letter"`, `"ANSI B"`…).
pub paper: Option<String>,
/// Custom paper size in millimetres (portrait width, height).
pub paper_mm: Option<(f64, f64)>,
pub landscape: Option<bool>,
/// Fit the drawing to the printable area. Default: on for model space, off for layouts (1:1).
pub fit: Option<bool>,
/// Plot scale in paper units per drawing unit (when not fitting).
pub scale: Option<f64>,
/// Model-space plot placement. `None` keeps the legacy centered behavior.
pub center: Option<bool>,
/// Model-space X/Y plot offset in millimetres from the printable area's lower-left corner.
pub offset: Option<[f64; 2]>,
/// Plot object lineweights. Default: the layout's setting; on for model space.
pub lineweights: Option<bool>,
/// Flate-compress the content stream.
pub compress: bool,
pub title: String,
/// Model space only: plot this rectangle (drawing units), fitted to the printable area,
/// instead of the extents. Ignored for layouts.
pub window: Option<Bounds2>,
/// Drawing layers become PDF layers (optional content groups). Default on.
pub layers: bool,
/// With `layers`: objects on layers that are off are plotted on PDF layers that start
/// hidden (frozen layers are never plotted). Default off: they aren't plotted.
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.
pub links: Vec<(Handle, String)>,
/// Write text in installed TrueType fonts as filled outlines instead of as text in an
/// embedded font subset (like AutoCAD's PDFSHX for its own fonts). Stroke-font text is
/// always geometry.
pub text_as_geometry: bool,
/// Margins (left, bottom, right, top; mm) of the plot device's paper, bounding the printable
/// area of a model-space plot. `None` keeps the page setup's (none for model space).
pub margins: Option<[f64; 4]>,
/// Entity hyperlinks read from a drawing file (`PE_URL` extended data) become link
/// annotations too, after [`PdfOptions::links`]. Default on.
pub hyperlinks: bool,
/// Plot objects with their transparency (PDF alpha). `None` = the page setup's "Plot
/// transparency" (off for model space).
pub plot_transparency: Option<bool>,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions {
plot_style_table: None,
paper: None,
paper_mm: None,
landscape: None,
fit: None,
scale: None,
center: None,
offset: None,
lineweights: None,
compress: false,
title: String::new(),
window: None,
layers: true,
include_off_layers: false,
links: Vec::new(),
text_as_geometry: false,
margins: None,
hyperlinks: true,
plot_transparency: None,
}
}
}
impl PdfOptions {
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, center?, offset?: [x,y] mm,
/// lineweights?, compress?, title?, window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm),
/// hyperlinks?: bool, plotTransparency?: bool}`.
pub fn from_json(v: &Value) -> PdfOptions {
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let paper_mm = match (num("width"), num("height")) {
(Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some((w, h)),
_ => None,
};
PdfOptions {
// `layers?` (default on), `offLayers?`, `links?: [{handle, url}]`, `text?: "text" |
// "geometry"`.
text_as_geometry: v.get("text").and_then(Value::as_str).is_some_and(|t| t.eq_ignore_ascii_case("geometry")),
layers: v.get("layers").and_then(Value::as_bool).unwrap_or(true),
include_off_layers: v.get("offLayers").and_then(Value::as_bool).unwrap_or(false),
links: links_json(v.get("links")),
plot_style_table: v.get("plotStyleTable").and_then(Value::as_str).map(|s| s.chars().take(260).collect()),
paper: v.get("paper").and_then(Value::as_str).map(str::to_string),
paper_mm,
landscape: v.get("landscape").and_then(Value::as_bool),
fit: v.get("fit").and_then(Value::as_bool),
scale: num("scale").filter(|s| *s > 0.0),
center: v.get("center").and_then(Value::as_bool),
offset: offset(v.get("offset")),
lineweights: v.get("lineweights").and_then(Value::as_bool),
compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true),
title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
window: window(v.get("window")),
margins: margins(v.get("margins")),
hyperlinks: v.get("hyperlinks").and_then(Value::as_bool).unwrap_or(true),
plot_transparency: v.get("plotTransparency").and_then(Value::as_bool),
}
}
}
fn offset(v: Option<&Value>) -> Option<[f64; 2]> {
let a = v?.as_array()?;
if a.len() != 2 {
return None;
}
let x = a.first()?.as_f64()?;
let y = a.get(1)?.as_f64()?;
valid_plot_offset([x, y]).then_some([x, y])
}
fn valid_plot_offset(offset: [f64; 2]) -> bool {
offset.iter().all(|v| v.is_finite() && v.abs() <= paper::MAX_PLOT_OFFSET_MM)
}
/// Reject malformed offsets at the JSON plotting boundary, before a permissive option parser
/// could treat them as absent.
pub(crate) fn validate_plot_offset_json(v: &Value) -> Result<()> {
if let Some(raw) = v.get("offset")
&& offset(Some(raw)).is_none()
{
return Err(IoError::Format(format!(
"plot offset must contain exactly two finite values from -{} to {} mm",
paper::MAX_PLOT_OFFSET_MM,
paper::MAX_PLOT_OFFSET_MM
)));
}
Ok(())
}
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
let a: Vec<f64> = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect();
match a[..] {
[l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]),
_ => None,
}
}
/// A plot window from `[[x1, y1], [x2, y2]]`: finite corners and a positive size, else `None`.
fn window(v: Option<&Value>) -> Option<Bounds2> {
let corner = |i: usize| {
let c = v?.get(i)?;
let p = Vec2::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?);
p.is_finite().then_some(p)
};
let (a, b) = (corner(0)?, corner(1)?);
let w = Bounds2::new(a, b);
(w.width() > 0.0 && w.height() > 0.0).then_some(w)
}
/// Plot with JSON options (the engine's `plot` hook).
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
validate_plot_offset_json(opts)?;
// `layouts: [name | "Model"] | "all"` plots those sheets into one file, one page each,
// instead of `space`; "all" = Model if it has objects, then the layouts in tab order.
let o = PdfOptions::from_json(opts);
// `pages: [{window: [[x1,y1],[x2,y2]], paper?, landscape?, scale?}]` plots model space once
// per window, one page each (a book of sheet frames), instead of `space`.
if let Some(pages) = opts.get("pages") {
let list = pages.as_array().ok_or_else(|| IoError::Format("`pages` must be a list of {window, paper?, landscape?}".into()))?;
if list.len() > MAX_PAGES {
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
}
let items = list
.iter()
.map(|pg| {
let w = window(pg.get("window")).ok_or_else(|| IoError::Format("each page needs a `window` with two corners".into()))?;
let mut po = PdfOptions::from_json(&serde_json::json!({
"paper": pg.get("paper"), "landscape": pg.get("landscape"), "scale": pg.get("scale"),
"plotStyleTable": opts.get("plotStyleTable"), "lineweights": opts.get("lineweights"),
"margins": opts.get("margins"), "text": opts.get("text"),
}));
po.window = Some(w);
po.fit = Some(true);
po.compress = o.compress;
Ok((Space::Model, po))
})
.collect::<Result<Vec<_>>>()?;
return pdf_items(d, &items, &o);
}
match opts.get("layouts") {
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => {
let mut layouts: Vec<_> = d.layouts.iter().collect();
layouts.sort_by_key(|l| l.tab_order);
let model = (!d.model.is_empty()).then_some(Space::Model);
let spaces: Vec<Space> = model.into_iter().chain(layouts.iter().map(|l| Space::Paper(l.name.clone()))).collect();
pdf_sheets(d, &spaces, &o)
}
Some(v) => {
let names = v.as_array().ok_or_else(|| IoError::Format("`layouts` must be a list of layout names".into()))?;
if names.len() > MAX_PAGES {
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
}
let spaces = names
.iter()
.map(|n| match n.as_str() {
Some(n) if n.eq_ignore_ascii_case("model") => Ok(Space::Model),
Some(n) => d.layout(n).map(|l| Space::Paper(l.name.clone())).ok_or_else(|| IoError::Format(format!("no layout `{n}`"))),
None => Err(IoError::Format("`layouts` must be a list of layout names".into())),
})
.collect::<Result<Vec<_>>>()?;
pdf_sheets(d, &spaces, &o)
}
None => pdf(d, space, &o),
}
}
/// `[{handle: "1A" | 26, url}]` → (handle, url) pairs; malformed entries are skipped.
fn links_json(v: Option<&Value>) -> Vec<(Handle, String)> {
let Some(a) = v.and_then(Value::as_array) else { return Vec::new() };
a.iter()
.take(MAX_LINKS)
.filter_map(|l| {
let h = l.get("handle")?;
let h = h.as_str().and_then(Handle::parse_hex).or_else(|| h.as_u64().map(Handle))?;
let url = l.get("url")?.as_str()?.trim();
(!url.is_empty()).then(|| (h, url.to_string()))
})
.collect()
}
/// The page setup a plot of `space` uses, with overrides applied.
pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup> {
let mut page = match space.layout() {
Some(n) => d.layout(n).map(|l| l.page.clone()).ok_or_else(|| IoError::Format(format!("no layout `{n}`")))?,
None => {
let mut p = PageSetup::default();
if paper::paper_unit_mm(d) == 1.0
&& let Some(a4) = paper::paper_size("A4")
{
p.paper = a4.name.into();
p.width_mm = a4.width_mm;
p.height_mm = a4.height_mm;
}
p
}
};
if let Some(name) = &o.paper {
let ps = paper::paper_size(name).ok_or_else(|| IoError::Format(format!("unknown paper size `{name}`")))?;
page.paper = ps.name.into();
page.width_mm = ps.width_mm;
page.height_mm = ps.height_mm;
}
if let Some((w, h)) = o.paper_mm {
page.paper = format!("User ({w:.2} x {h:.2} MM)");
page.width_mm = w.min(MAX_SHEET_MM);
page.height_mm = h.min(MAX_SHEET_MM);
}
if let Some(l) = o.landscape {
page.landscape = l;
}
if let Some(t) = &o.plot_style_table {
page.plot_style_table = t.clone();
}
// The plot device's margins bound a model-space plot (a layout's come with its page setup).
if let (Space::Model, Some(m)) = (space, o.margins) {
page.margins_mm = m;
}
page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM);
page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM);
Ok(page)
}
/// World → paper-unit mapping: `p' = (p - from) * s + to`.
#[derive(Clone, Copy)]
struct Map {
from: Vec2,
s: f64,
to: Vec2,
/// Points per paper unit.
k: f64,
}
impl Map {
fn pt(&self, p: Vec2) -> Vec2 {
((p - self.from) * self.s + self.to) * self.k
}
}
/// Write a one-page vector PDF of a space.
pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<Vec<u8>> {
pdf_sheets(d, std::slice::from_ref(space), o)
}
/// The name of a sheet: `Model` or the layout's.
fn sheet_name(space: &Space) -> String {
space.tab_name().to_string()
}
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
/// layout name. The options apply to every sheet; layouts use their own page setups.
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
pdf_items(d, &items, o)
}
/// Write a vector PDF with one page per `(space, options)` item (at most [`MAX_PAGES`]): each
/// page plots with its own options (window, paper, orientation); `o` supplies the file-wide
/// ones (compression, title, layers, links).
pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) -> Result<Vec<u8>> {
if items.iter().any(|(_, io)| io.offset.is_some_and(|offset| !valid_plot_offset(offset))) {
return Err(IoError::Format("plot offset must contain two finite values within ±100000 mm".into()));
}
if items.is_empty() {
return Err(IoError::Format("there are no sheets to plot".into()));
}
if items.len() > MAX_PAGES {
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
}
match write_pdf(d, items, o, o.text_as_geometry) {
// A font the PDF writer can't embed after all, or one that makes it fail internally:
// plot the text as outlines instead. Outlines embed no font, so this runs at most once.
Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io),
r => r.map_err(Failure::into_io),
}
}
/// Whether a writer failure may come from embedding a font: a font the writer refused, or a
/// panic inside the writer (caught by `guard`), which a malformed font can cause.
fn font_failure(e: &WriteError) -> bool {
match e {
WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)),
WriteError::BackendPanic(_) => true,
_ => false,
}
}
/// Why writing failed: our own error, or the PDF writer's.
enum Failure {
Io(IoError),
Write(WriteError),
}
impl From<IoError> for Failure {
fn from(e: IoError) -> Self {
Failure::Io(e)
}
}
impl From<WriteError> for Failure {
fn from(e: WriteError) -> Self {
Failure::Write(e)
}
}
impl Failure {
fn into_io(self) -> IoError {
match self {
Failure::Io(e) => e,
Failure::Write(e) => IoError::Format(format!("PDF export failed: {e}")),
}
}
}
/// 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.
struct SheetPage {
/// Page size in points.
media: Vec2,
/// What it draws, the mapping to points and the plot area (points).
list: cadcraft_render::DisplayList,
map: Map,
clip_pt: Bounds2,
/// The drawing layers (indices in the layer table) 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)>,
/// Page label: the layout name.
label: String,
}
/// `o` with a layout's saved page setup filled in where the options don't say: its plot window,
/// scale to fit or saved scale, and centring (#2493). Explicit options win; model space has no
/// saved setup.
pub(crate) fn with_saved_setup(d: &Drawing, space: &Space, o: &PdfOptions) -> PdfOptions {
let mut o = o.clone();
let Some(page) = space.layout().and_then(|n| d.layout(n)).map(|l| &l.page) else { return o };
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot of the sheet.
if o.fit == Some(false) && o.scale.is_none() {
o.window = None;
return o;
}
let saved = paper::saved_plot(page);
// A layout is plotted over its own sheet: only its saved window applies, not one in the options.
o.window = saved.window;
// An explicit scale beats a saved scale to fit (the scale is ignored while fitting).
if o.fit.is_none() && o.scale.is_none() && saved.fit {
o.fit = Some(true);
}
if o.scale.is_none() {
o.scale = saved.scale;
}
if o.center.is_none() && saved.window.is_some() {
o.center = Some(saved.center);
}
o
}
/// Plot one space.
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result<SheetPage> {
let saved = with_saved_setup(d, space, o);
let o = &saved;
let page = page_for(d, space, o)?;
let unit_mm = paper::page_unit_mm(d, &page);
let sheet = Sheet::from_page(&page, unit_mm);
let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true });
let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new);
let ropts = cadcraft_render::Options {
transparency: o.plot_transparency.unwrap_or(page.plot_transparency),
tolerance: 0.001,
min_dash: 0.0,
text: true,
linetypes: true,
fill: true,
lineweights,
view_height: 0.0,
plot_style_table,
view: None,
skip_viewport: None,
cancel: None,
};
let k = unit_mm * PT_PER_MM;
let window = o.window.filter(|w| !w.is_empty());
let fit = o.fit.unwrap_or(matches!(space, Space::Model));
// Chord tolerance: about 0.05 mm on paper.
let est = plot_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, fit, o.scale);
let tol = 0.05 / unit_mm / est.max(1e-300);
let tolerance = if tol.is_finite() && tol > 0.0 { tol } else { ropts.tolerance };
// Relative point sizes follow the plotted window (else the extents).
let view_height = window.map(|w| w.height()).unwrap_or(0.0);
let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, view_height, ..ropts });
let b = window.unwrap_or(list.bounds);
let s = plot_scale(&b, &sheet, fit, o.scale);
// A layout plotted over a window is placed like model space: the window on the printable area.
let placed = fit || window.is_some() || matches!(space, Space::Model);
let map = if placed {
let by = if window.is_some() { &Space::Model } else { space };
let (from, to) = placement(by, &b, fit, &sheet, o);
Map { from, s, to, k }
} else {
Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k }
};
// Plot style ends, joins and fill patterns at the plot scale (a layout's are already drawn).
let mut list = list;
cadcraft_render::pen::apply(&mut list, map.s * unit_mm);
let media = sheet.size * k;
let clip_pt = if placed { Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) } else { Bounds2::new(Vec2::ZERO, media) };
// The layers drawn on, in order of first use.
let mut layers: Vec<u32> = Vec::new();
if o.layers {
let mut seen = std::collections::HashSet::new();
for l in &list.prim_layers {
if (*l as usize) < d.layers.len() && layers.len() < MAX_LAYERS && seen.insert(*l) {
layers.push(*l);
}
}
}
let links = links(&list, &map, &clip_pt, links_to);
Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) })
}
/// Source and destination origins for the shared model-space PDF/PNG placement behavior.
pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Sheet, o: &PdfOptions) -> (Vec2, Vec2) {
if matches!(space, Space::Model) {
let from = if bounds.is_empty() {
Vec2::ZERO
} else if o.center.unwrap_or(true) {
bounds.center()
} else {
bounds.min
};
let to = if o.center.unwrap_or(true) {
sheet.printable.center()
} else {
let offset = o.offset.unwrap_or([0.0, 0.0]);
sheet.printable.min + Vec2::new(offset[0] / sheet.unit_mm, offset[1] / sheet.unit_mm)
};
(from, to)
} else if fit {
(if bounds.is_empty() { Vec2::ZERO } else { bounds.center() }, sheet.printable.center())
} else {
(Vec2::ZERO, Vec2::ZERO)
}
}
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
if !o.hyperlinks {
return out;
}
let given: std::collections::HashSet<Handle> = out.iter().map(|(h, _)| *h).collect();
let mut read: Vec<(Handle, String)> = std::iter::once(&d.model)
.chain(d.layouts.iter().map(|l| &l.entities))
.chain(d.blocks.values().map(|b| &b.entities))
.flat_map(|s| s.iter())
.filter(|e| !given.contains(&e.handle))
.filter_map(|e| Some((e.handle, pe_url(&e.common.xdata)?)))
.collect();
read.sort_by_key(|(h, _)| *h);
read.dedup_by_key(|(h, _)| *h);
out.extend(read.into_iter().take(MAX_LINKS.saturating_sub(out.len())));
out
}
/// The link target of `PE_URL` extended data: the first string (group 1000) after
/// `1001 PE_URL`, before the next application's data.
fn pe_url(xdata: &[cadcraft_doc::RawTag]) -> Option<String> {
let start = xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("PE_URL"))?;
let url = xdata.get(start + 1..)?.iter().take_while(|t| t.code != 1001).find(|t| t.code == 1000)?.value.trim();
(!url.is_empty() && url.len() <= MAX_URL).then(|| url.to_string())
}
/// Link annotation rectangles: the area (in points) each linked entity covers on the sheet,
/// clipped to the plot area.
fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, links: &[(Handle, String)]) -> Vec<(Bounds2, String)> {
if links.is_empty() {
return Vec::new();
}
// Membership by handle, built once, so scanning primitives is O(primitives) rather than
// O(primitives × links): otherwise the two counts multiply into excessive work (CWE-407).
let linked: std::collections::HashSet<Handle> = links.iter().map(|(h, _)| *h).collect();
let mut areas: std::collections::HashMap<Handle, Bounds2> = std::collections::HashMap::new();
for p in &list.prims {
if matches!(p.kind, Kind::Infinite { .. }) || !linked.contains(&p.handle) {
continue;
}
let b = areas.entry(p.handle).or_insert(Bounds2::EMPTY);
for q in list.points(p) {
let q = map.pt(*q);
if q.is_finite() {
b.add(q);
}
}
}
let mut out = Vec::new();
for (h, url) in links {
let Some(b) = areas.get(h).filter(|b| !b.is_empty()) else { continue };
// A little slack so thin lines are easy to click.
let b = b.expand(2.0);
let r = Bounds2 { min: b.min.max(clip_pt.min), max: b.max.min(clip_pt.max) };
if r.min.x < r.max.x && r.min.y < r.max.y {
out.push((r, url.clone()));
}
}
out
}
/// Paper units per drawing unit for a plot.
pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64 {
if let Some(s) = scale.filter(|s| s.is_finite() && *s > 0.0)
&& !fit
{
return s;
}
if !fit || b.is_empty() {
return 1.0;
}
let pw = sheet.printable.width();
let ph = sheet.printable.height();
let s = (pw / b.width().max(1e-12)).min(ph / b.height().max(1e-12));
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
/// Largest coordinate written (points); a primitive reaching further is clipped to the plot
/// area first. The PDF writer's geometry is `f32`.
const MAX_COORD: f64 = 1.0e7;
/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range.
fn coord(p: Vec2) -> Option<(f32, f32)> {
let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
(ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32))
}
/// A rectangle path between two corners (points), if it has an area.
fn rect_path(min: Vec2, max: Vec2) -> Option<Path> {
let (x0, y0) = coord(min)?;
let (x1, y1) = coord(max)?;
let mut pb = PathBuilder::new();
pb.push_rect(Rect::from_ltrb(x0, y0, x1, y1)?);
pb.finish()
}
/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its
/// own, and this is about the thinnest mark that still prints.
const HAIRLINE_DOT_PT: f32 = 0.25;
/// A circle path (points) around `(x, y)`: four cubic arcs.
fn circle_path(x: f32, y: f32, r: f32) -> Option<Path> {
// Control-point distance of a quarter-circle cubic, as a fraction of the radius.
let k = r * 0.552_284_8;
let mut pb = PathBuilder::new();
pb.move_to(x + r, y);
pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r);
pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y);
pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r);
pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y);
pb.close();
pb.finish()
}
/// Write one sheet as a page: its size, label, link annotations and marks.
fn write_page(doc: &mut kr::Document, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
let (w, h) = (p.media.x as f32, p.media.y as f32);
let label = PageLabel::new(None, Some(p.label.chars().take(MAX_TEXT).collect()), None);
let settings = PageSettings::from_wh(w, h).ok_or_else(|| IoError::Format("the sheet has no area".into()))?.with_page_label(label);
let mut page = doc.start_page_with(settings);
for (r, url) in &p.links {
let Some(uri) = uri(url) else { continue };
// Annotation rectangles are given top-down, like the writer's page surface.
let Some(rect) = Rect::from_ltrb(r.min.x as f32, h - r.max.y as f32, r.max.x as f32, h - r.min.y as f32) else { continue };
let link = LinkAnnotation::new(rect, Target::Action(LinkAction::new(uri).into()));
page.add_annotation(Annotation::new_link(link, None));
}
let mut surface = page.surface();
// The surface's y axis points down; flip it so we draw in PDF points (y up).
surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h));
// Clip to the plot area.
let mut drawn = Ok(());
if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) {
surface.push_clip_path(&clip, &FillRule::NonZero);
drawn = marks(&mut surface, p, groups, fonts);
surface.pop();
}
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 = &p.list;
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(s, &mut batch);
layer_changed(s, layer, want)?;
layer = want;
}
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 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 };
let m = Mark { rgb, t, w };
match prim.kind {
Kind::Polyline | Kind::Infinite { .. } => polyline(s, &mut batch, p, prim, raw, m),
Kind::Tris | Kind::Mask => {
flush(s, &mut batch);
// A wipeout's mask is paper white.
let m = if prim.kind == Kind::Mask { Mark { rgb: white, ..m } } else { m };
let text_font =
u32::try_from(i).ok().and_then(|i| texts.get(&i).copied()).and_then(|text| fonts.font(&text.glyph.face).map(|font| (text, font)));
match text_font {
Some((text, font)) => glyph(s, font, text, &p.map, m.rgb, m.t),
None => fill_tris(s, p, raw, m.rgb, m.t),
}
}
Kind::Point => point(s, &mut batch, &p.map, raw, m),
// Raster images are not written to PDF yet: their frame is.
Kind::Image { .. } => {}
}
}
flush(s, &mut batch);
layer_changed(s, layer, None)
}
/// How a primitive is painted: colour, transparency (percent) and lineweight (points).
#[derive(Clone, Copy)]
struct Mark {
rgb: Rgb,
t: u8,
w: f64,
}
/// Stroke a polyline or infinite line, clipped to the plot area, into the open batch.
fn polyline(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, p: &SheetPage, prim: &DPrim, raw: &[Vec2], m: Mark) {
let Some(pts) = polyline_points(prim, raw, &p.map, &p.clip_pt) else { return };
if pts.len() < 2 {
return;
}
// 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(m.w + 1.0)) };
let style = StrokeStyle { rgb: m.rgb, width: m.w as f32, transparency: m.t };
if m.t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
flush(s, batch);
}
for run in runs {
stroke_run(s, batch, &run, style, m);
}
}
/// The page points of a polyline, or of an infinite line clipped to the plot area; `None` when
/// there is nothing to draw.
fn polyline_points(prim: &DPrim, raw: &[Vec2], map: &Map, clip_pt: &Bounds2) -> Option<Vec<Vec2>> {
match prim.kind {
Kind::Infinite { ray } => {
let (Some(base), Some(dir)) = (raw.first(), raw.get(1)) else { return None };
let a = map.pt(*base);
let dir_pt = map.pt(*base + *dir) - a;
let (x, y) = clip::clip_infinite(a, dir_pt, ray, clip_pt)?;
Some(vec![x, y])
}
_ => Some(raw.iter().map(|q| map.pt(*q)).collect()),
}
}
/// Add one run of a polyline to the batch, or paint it as a dot when it has no extent.
fn stroke_run(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, run: &[Vec2], style: StrokeStyle, m: Mark) {
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { return };
// 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, batch);
let d = if m.w > 0.0 { m.w as f32 } else { HAIRLINE_DOT_PT };
if let Some(path) = circle_path(x, y, d / 2.0) {
fill_path(s, &path, m.rgb, m.t);
}
return;
}
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);
}
}
}
/// Fill a set of triangles, clipped to the plot area.
fn fill_tris(s: &mut Surface<'_>, p: &SheetPage, raw: &[Vec2], rgb: Rgb, t: u8) {
let mapped: Vec<Vec2> = raw.iter().map(|q| p.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);
}
}
pb.close();
}
if let Some(path) = pb.finish() {
fill_path(s, &path, rgb, t);
}
}
/// Paint a point marker: a small square at the point.
fn point(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, map: &Map, raw: &[Vec2], m: Mark) {
let Some(q) = raw.first() else { return };
flush(s, batch);
let half = (m.w.max(0.5)) / 2.0;
let c = map.pt(*q);
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
fill_path(s, &path, m.rgb, m.t);
}
}
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
let k = map.s * map.k;
let g = &t.glyph;
let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return };
let det = a * d - b * c;
if !det.is_finite() || det == 0.0 {
return;
}
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 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::with_capacity(url.len());
for b in url.bytes() {
match b {
0x21..=0x7E => out.push(char::from(b)),
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
Some(out)
}
#[cfg(test)]
mod saved_setup_tests {
use super::*;
use cadcraft_doc::{Common, EntityKind, Layout, Line};
use cadcraft_geom::Vec3;
use serde_json::json;
/// A layout drawn in mm at A1 size (frame 20,5 - 836,589) whose page setup is A4 landscape
/// plotting that window, as in #2493.
fn drawing(scale_to_fit: bool, scale: f64) -> Drawing {
let mut d = Drawing::new_metric();
let mut l = Layout::new("L1", 3);
l.page = PageSetup {
width_mm: 297.0,
height_mm: 210.0,
landscape: true,
margins_mm: [0.0; 4],
plot_area: "window".into(),
plot_window: [20.0, 5.0, 836.0, 589.0],
scale_to_fit,
scale,
center: true,
..PageSetup::default()
};
d.layouts.push(l);
let line = EntityKind::Line(Line { a: Vec3::new(20.0, 5.0, 0.0), b: Vec3::new(836.0, 589.0, 0.0) });
d.add(&Space::Paper("L1".into()), Common::default(), line).unwrap();
d
}
fn map(d: &Drawing, opts: serde_json::Value) -> Map {
sheet(d, &Space::Paper("L1".into()), &PdfOptions::from_json(&opts), &[]).unwrap().map
}
/// A layout plotted with no options uses its saved scale to fit and plot window, not 1:1.
#[test]
fn a_layout_plots_its_saved_window_scaled_to_fit_by_default() {
let d = drawing(true, 1.0);
let m = map(&d, json!({}));
let want = (297.0_f64 / 816.0).min(210.0 / 584.0);
assert!((m.s - want).abs() < 1e-9, "scale {} not {want}", m.s);
assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9, "window centre, got {:?}", m.from);
assert!(m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "page centre, got {:?}", m.to);
}
/// The options still win: `fit: false` is the plain 1:1 plot, and a `scale` is used as given.
#[test]
fn explicit_options_override_the_saved_setup() {
let d = drawing(true, 1.0);
let m = map(&d, json!({"fit": false}));
assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO));
let m = map(&d, json!({"fit": false, "scale": 0.25}));
assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s);
}
/// A saved scale (not scale to fit) is used by default, with the window centred on the page.
#[test]
fn a_layout_plots_at_its_saved_scale_by_default() {
let d = drawing(false, 0.3394);
let m = map(&d, json!({}));
assert!((m.s - 0.3394).abs() < 1e-12, "{}", m.s);
assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "{:?} {:?}", m.from, m.to);
}
/// A page setup with nothing saved to fit plots as it always did.
#[test]
fn a_layout_without_a_saved_window_or_scale_is_unchanged() {
let mut d = drawing(false, 1.0);
d.layouts.iter_mut().find(|l| l.name == "L1").unwrap().page.plot_area = "layout".into();
let m = map(&d, json!({}));
assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO));
}
/// An explicit `scale` beats a saved scale to fit, in the PDF as in the preview.
#[test]
fn an_explicit_scale_overrides_the_saved_scale_to_fit() {
let d = drawing(true, 1.0);
let m = map(&d, json!({"scale": 0.25}));
assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s);
assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "still placed: {:?} {:?}", m.from, m.to);
}
}