forked from 1b-agent/opencadstudio-upstream
Everything was written and only clipped at view time, so a window over a corner of a large drawing produced a near-empty page of several MB. Wires and hatches whose sheet bounds miss the window are now skipped. Fixes #1568 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3034 lines
117 KiB
Rust
3034 lines
117 KiB
Rust
// PDF export — converts the paper-space wire model to a PDF file using printpdf.
|
||
//
|
||
// Each WireModel becomes a sequence of DrawLine operations. NaN values in the
|
||
// points array act as segment separators (pen-up).
|
||
//
|
||
// Coordinate system: CAD uses mm units with origin at bottom-left and Y up.
|
||
// printpdf's Point::new(Mm, Mm) also has origin at bottom-left, so no Y-flip
|
||
// is needed — we shift the coordinates by (offset_x, offset_y) to place the
|
||
// drawing origin at the paper origin.
|
||
|
||
use crate::io::plot_style::PlotStyleTable;
|
||
use crate::scene::model::hatch_model::HatchModel;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::scene::model::hatch_model::HatchPattern;
|
||
use crate::scene::WireModel;
|
||
use crate::scene::model::image_model::ImageModel;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::scene::model::wire_model::SearchableTextRun;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use printpdf::{
|
||
BlendMode, BuiltinFont, Codepoint, Color, ExtendedGraphicsState, ExtendedGraphicsStateId,
|
||
FontId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode,
|
||
ParsedFont, PdfDocument, PdfFont, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon,
|
||
PolygonRing, Pt, Rgb, TextItem, TextMatrix, TextRenderingMode, WindingOrder,
|
||
};
|
||
use std::path::Path;
|
||
|
||
#[derive(Clone, Debug)]
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
pub struct PlotWire {
|
||
pub wire: WireModel,
|
||
pub draw_depth: f32,
|
||
}
|
||
|
||
impl std::ops::Deref for PlotWire {
|
||
type Target = WireModel;
|
||
|
||
fn deref(&self) -> &Self::Target {
|
||
&self.wire
|
||
}
|
||
}
|
||
|
||
/// Decoded image geometry plus inherited block/viewport clip boundaries.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PlotImage {
|
||
pub image: ImageModel,
|
||
pub clips: Vec<Vec<[f64; 2]>>,
|
||
}
|
||
|
||
// The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via
|
||
// lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web
|
||
// build gets these stubs so the call sites still compile.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> {
|
||
Err("PDF export is not available in the web version.".into())
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub fn export_pdf_pages(
|
||
_pages: &[PdfPageInput],
|
||
_path: &Path,
|
||
_plot_style: Option<&PlotStyleTable>,
|
||
) -> Result<(), String> {
|
||
Err("PDF export is not available in the web version.".into())
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub async fn pick_pdf_path_owned(_stem: String) -> Option<std::path::PathBuf> {
|
||
None
|
||
}
|
||
|
||
/// mm to PDF points (1 mm = 2.834645 pt).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const MM_TO_PT: f32 = 2.834645;
|
||
/// `wire.line_weight_px` is the on-screen pixel weight. Convert the 96-dpi
|
||
/// pixels to points while retaining the viewport's lineweight visibility
|
||
/// boost, so "As displayed" output has the same visual hierarchy as the
|
||
/// canvas instead of making 0.35 mm ByLayer outlines look half as thick.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const LW_PX_TO_PT: f32 = MM_TO_PT / (96.0 / 25.4);
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const SCREEN_DOT_MM: f32 = 25.4 / 96.0;
|
||
|
||
/// Output controls shared by preview, PDF export, and printer rendering.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct PdfPlotOptions {
|
||
pub object_lineweights: bool,
|
||
pub scale_lineweights: bool,
|
||
pub transparency: bool,
|
||
pub stamp: bool,
|
||
pub merge_lines: bool,
|
||
}
|
||
|
||
/// End indexes of the first paper/model render group in each flat input list.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct PlotGroupSplits {
|
||
pub wires: usize,
|
||
pub hatches: usize,
|
||
pub wipeouts: usize,
|
||
pub images: usize,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct PlotContent {
|
||
pub wires: std::sync::Arc<Vec<PlotWire>>,
|
||
pub hatches: Vec<HatchModel>,
|
||
pub wipeouts: Vec<HatchModel>,
|
||
pub images: Vec<PlotImage>,
|
||
pub group_splits: PlotGroupSplits,
|
||
}
|
||
|
||
/// Owned geometry and settings for one PDF page.
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
pub struct PdfPageInput {
|
||
pub content: PlotContent,
|
||
/// Page dimensions in mm, after any 90/270-degree rotation.
|
||
pub paper_w: f64,
|
||
pub paper_h: f64,
|
||
/// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates.
|
||
pub offset_x: f64,
|
||
pub offset_y: f64,
|
||
pub rotation_deg: i32,
|
||
pub scale: f32,
|
||
pub clip: Option<(f32, f32, f32, f32)>,
|
||
pub options: PdfPlotOptions,
|
||
pub plot_style: Option<PlotStyleTable>,
|
||
}
|
||
|
||
impl Default for PdfPlotOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
object_lineweights: true,
|
||
scale_lineweights: false,
|
||
transparency: false,
|
||
stamp: false,
|
||
merge_lines: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Public entry point ────────────────────────────────────────────────────
|
||
|
||
/// Export one page to a PDF file.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> {
|
||
export_pdf_pages(std::slice::from_ref(page), path, None)
|
||
}
|
||
|
||
/// Export several independently sized pages into one PDF file.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn export_pdf_pages(
|
||
pages: &[PdfPageInput],
|
||
path: &Path,
|
||
plot_style: Option<&PlotStyleTable>,
|
||
) -> Result<(), String> {
|
||
if pages.is_empty() {
|
||
return Err("No pages were selected.".into());
|
||
}
|
||
let bytes = build_pdf_pages(pages, plot_style)?;
|
||
write_pdf_atomically(path, &bytes)
|
||
}
|
||
|
||
/// Write a complete PDF beside the destination, then replace it atomically.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||
let temp_path = super::save_temp_path(path);
|
||
if let Err(error) = std::fs::write(&temp_path, bytes) {
|
||
let _ = std::fs::remove_file(&temp_path);
|
||
return Err(format!("Failed to write PDF data: {error}"));
|
||
}
|
||
if let Err(error) = super::replace_save_file(&temp_path, path) {
|
||
let _ = std::fs::remove_file(&temp_path);
|
||
return Err(format!("Failed to replace PDF file: {error}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: rings
|
||
.into_iter()
|
||
.map(|ring| PolygonRing {
|
||
points: ring
|
||
.into_iter()
|
||
.map(|[x, y]| LinePoint {
|
||
p: Point { x: Pt(x), y: Pt(y) },
|
||
bezier: false,
|
||
})
|
||
.collect(),
|
||
})
|
||
.collect(),
|
||
mode: PaintMode::Clip,
|
||
winding_order: WindingOrder::EvenOdd,
|
||
},
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_image(
|
||
doc: &mut PdfDocument,
|
||
resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||
ops: &mut Vec<Op>,
|
||
plot: &PlotImage,
|
||
ox: f64,
|
||
oy: f64,
|
||
options: PdfPlotOptions,
|
||
) -> Result<(), String> {
|
||
let image = &plot.image;
|
||
let expected_byte_count = (image.width as usize)
|
||
.checked_mul(image.height as usize)
|
||
.and_then(|n| n.checked_mul(4));
|
||
if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) {
|
||
return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into());
|
||
}
|
||
if image.verts.len() < 3 || image.verts.len() % 3 != 0 {
|
||
return Err("Cannot plot bitmap: incomplete triangle geometry.".into());
|
||
}
|
||
if options.transparency && image.opacity <= 0.0 {
|
||
return Ok(());
|
||
}
|
||
let to_page = |high: [f32; 3], low: [f32; 3]| {
|
||
[
|
||
((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32,
|
||
((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32,
|
||
]
|
||
};
|
||
let corners: [[f32; 2]; 4] =
|
||
std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i]));
|
||
if !corners.iter().flatten().all(|v| v.is_finite())
|
||
|| !image.verts.iter().all(|v| {
|
||
to_page(v.pos, v.pos_low)
|
||
.iter()
|
||
.chain(&v.uv)
|
||
.all(|c| c.is_finite())
|
||
})
|
||
|| !plot
|
||
.clips
|
||
.iter()
|
||
.all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite()))
|
||
|| !image.opacity.is_finite()
|
||
{
|
||
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
|
||
}
|
||
// The low bit (free: the pixel buffer is aligned) keeps the opaque copy
|
||
// of a picture apart from its transparent one.
|
||
let key = (
|
||
std::sync::Arc::as_ptr(&image.pixels) as usize | usize::from(!image.use_alpha),
|
||
image.width,
|
||
image.height,
|
||
);
|
||
let id = resources
|
||
.entry(key)
|
||
.or_insert_with(|| {
|
||
let id = printpdf::XObjectId::new();
|
||
// add_image clones its argument; the public map accepts owned pixels.
|
||
doc.resources.xobjects.map.insert(
|
||
id.clone(),
|
||
printpdf::XObject::Image(printpdf::RawImage {
|
||
pixels: printpdf::RawImageData::U8(if image.use_alpha {
|
||
image.pixels.as_ref().clone()
|
||
} else {
|
||
// Transparency off: every pixel opaque in its colour.
|
||
let mut opaque = image.pixels.as_ref().clone();
|
||
opaque.chunks_exact_mut(4).for_each(|px| px[3] = 255);
|
||
opaque
|
||
}),
|
||
width: image.width as usize,
|
||
height: image.height as usize,
|
||
data_format: printpdf::RawImageFormat::RGBA8,
|
||
tag: Vec::new(),
|
||
}),
|
||
);
|
||
id
|
||
})
|
||
.clone();
|
||
ops.push(Op::SaveGraphicsState);
|
||
if options.transparency && image.opacity < 1.0 {
|
||
// printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside
|
||
// this image's saved state so the nonstroking alpha is correct.
|
||
let alpha = image.opacity.clamp(0.0, 1.0);
|
||
let gs = doc.add_graphics_state(
|
||
ExtendedGraphicsState::default()
|
||
.with_current_fill_alpha(alpha)
|
||
.with_current_stroke_alpha(alpha),
|
||
);
|
||
ops.push(Op::LoadGraphicsState { gs });
|
||
}
|
||
for clip in &plot.clips {
|
||
image_clip(
|
||
ops,
|
||
vec![clip
|
||
.iter()
|
||
.map(|p| {
|
||
[
|
||
((p[0] + ox) * MM_TO_PT as f64) as f32,
|
||
((p[1] + oy) * MM_TO_PT as f64) as f32,
|
||
]
|
||
})
|
||
.collect()],
|
||
);
|
||
}
|
||
let draw = |ops: &mut Vec<Op>, matrix: [f32; 6]| {
|
||
ops.push(Op::SetTransformationMatrix {
|
||
matrix: printpdf::CurTransMat::Raw(matrix),
|
||
});
|
||
ops.push(Op::UseXobject {
|
||
id: id.clone(),
|
||
// Cancel printpdf's pixel-size transform: matrix maps the PDF
|
||
// image's unit square directly onto the CAD quad in page points.
|
||
transform: printpdf::XObjectTransform {
|
||
dpi: Some(72.0),
|
||
scale_x: Some(1.0 / image.width as f32),
|
||
scale_y: Some(1.0 / image.height as f32),
|
||
..Default::default()
|
||
},
|
||
});
|
||
};
|
||
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
|
||
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
|
||
let affine = (0..2).all(|axis| {
|
||
(corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs()
|
||
<= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0)
|
||
});
|
||
if affine {
|
||
// A single draw preserves intrinsic IMAGE clipping without overlapping image draws.
|
||
image_clip(
|
||
ops,
|
||
image
|
||
.verts
|
||
.chunks_exact(3)
|
||
.map(|tri| {
|
||
tri.iter()
|
||
.map(|vertex| to_page(vertex.pos, vertex.pos_low))
|
||
.collect()
|
||
})
|
||
.collect(),
|
||
);
|
||
draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]);
|
||
} else {
|
||
// PDF transforms are affine; perspective sampling is approximate.
|
||
for triangle in image.verts.chunks_exact(3) {
|
||
let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low));
|
||
let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]);
|
||
let matrix = image_triangle_matrix(page, uv)
|
||
.filter(|matrix| matrix.iter().all(|v| v.is_finite()))
|
||
.ok_or("Cannot plot bitmap: degenerate texture mapping.")?;
|
||
ops.push(Op::SaveGraphicsState);
|
||
image_clip(ops, vec![page.to_vec()]);
|
||
draw(ops, matrix);
|
||
ops.push(Op::RestoreGraphicsState);
|
||
}
|
||
}
|
||
ops.push(Op::RestoreGraphicsState);
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> {
|
||
let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
||
let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
||
let determinant = u[0] * v[1] - v[0] * u[1];
|
||
if determinant.abs() < 1e-12 {
|
||
return None;
|
||
}
|
||
let axes: [[f32; 2]; 2] = std::array::from_fn(|i| {
|
||
[
|
||
((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant,
|
||
(u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant,
|
||
]
|
||
});
|
||
Some([
|
||
axes[0][0],
|
||
axes[1][0],
|
||
axes[0][1],
|
||
axes[1][1],
|
||
p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1],
|
||
p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1],
|
||
])
|
||
}
|
||
|
||
/// Show a parented PDF save-file dialog and return the chosen path.
|
||
///
|
||
/// The parent comes from `iced::window::run`, keeping the portal request tied
|
||
/// to the visible app window on Wayland instead of silently resolving to
|
||
/// `None` on desktops that reject a parentless save dialog (#537).
|
||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||
pub fn pick_pdf_path_owned(
|
||
stem: String,
|
||
parent: &dyn iced::window::Window,
|
||
) -> Option<std::path::PathBuf> {
|
||
let path = crate::sys::blocking_file_dialog()
|
||
.set_parent(parent)
|
||
.set_title(crate::t!("Export as PDF").as_ref())
|
||
.set_file_name(&format!("{stem}.pdf"))
|
||
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
|
||
.add_filter(crate::t!("All Files").as_ref(), &["*"])
|
||
.save_file()
|
||
?;
|
||
crate::config::remember_dialog_dir(&path);
|
||
Some(path)
|
||
}
|
||
|
||
/// Windows: pick the PDF destination with the async dialog on a worker
|
||
/// thread. The parented blocking dialog ran `IFileDialog::Show` on the UI
|
||
/// thread inside the window callback, and when the target name already
|
||
/// existed the overwrite-confirmation popup is a second nested modal that
|
||
/// never gets pumped there — the app froze instead of asking. The async
|
||
/// backend runs the dialog off-thread; it is the same pattern the DWG
|
||
/// Save As flow uses, whose confirm popup works.
|
||
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
|
||
pub async fn pick_pdf_path_async(stem: String) -> Option<std::path::PathBuf> {
|
||
let handle = crate::sys::file_dialog()
|
||
.set_title(crate::t!("Export as PDF").as_ref())
|
||
.set_file_name(format!("{stem}.pdf"))
|
||
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
|
||
.add_filter(crate::t!("All Files").as_ref(), &["*"])
|
||
.save_file()
|
||
.await?;
|
||
Some(crate::sys::handle_path(&handle))
|
||
}
|
||
|
||
// ── PDF builder ───────────────────────────────────────────────────────────
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
|
||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||
// Subset-embed one TrueType font per used system family before any page
|
||
// references them (fonts live on the document resource dictionary).
|
||
let fonts = SearchableFontSet::build(&mut doc, pages);
|
||
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
|
||
// Allocation addresses cannot be reused by another source during this export.
|
||
let mut image_resources = std::collections::HashMap::new();
|
||
let mut text_fonts = TextFonts::default();
|
||
for (index, page) in pages.iter().enumerate() {
|
||
append_pdf_page(
|
||
&mut doc,
|
||
&mut image_resources,
|
||
&mut text_fonts,
|
||
page,
|
||
plot_style,
|
||
&fonts,
|
||
)
|
||
.map_err(|error| format!("Page {}: {error}", index + 1))?;
|
||
}
|
||
// Subset and embed the TrueType faces the text was drawn with, now that
|
||
// every page has recorded which glyphs it uses.
|
||
text_fonts.install(&mut doc);
|
||
let mut warnings = Vec::new();
|
||
// printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented
|
||
// out), so every page's content stream — megabytes of vector operators
|
||
// for a CAD sheet — went out uncompressed: a 16-sheet set weighed 280 MB.
|
||
// Serialise through lopdf ourselves and Flate-compress the streams.
|
||
let mut lo = printpdf::to_lopdf_doc(&doc, &PdfSaveOptions::default(), &mut warnings);
|
||
lo.compress();
|
||
let mut bytes = Vec::new();
|
||
lo.save_to(&mut bytes)
|
||
.map_err(|error| format!("PDF serialisation failed: {error}"))?;
|
||
Ok(bytes)
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn append_pdf_page(
|
||
doc: &mut PdfDocument,
|
||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||
text_fonts: &mut TextFonts,
|
||
page: &PdfPageInput,
|
||
fallback_plot_style: Option<&PlotStyleTable>,
|
||
fonts: &SearchableFontSet,
|
||
) -> Result<(), String> {
|
||
let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content;
|
||
let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
|
||
let (ox, oy) = (page.offset_x, page.offset_y);
|
||
let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip);
|
||
let plot_style = page.plot_style.as_ref().or(fallback_plot_style);
|
||
let options = page.options;
|
||
let mut ops: Vec<Op> = Vec::new();
|
||
|
||
// White page background.
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb {
|
||
r: 1.0,
|
||
g: 1.0,
|
||
b: 1.0,
|
||
icc_profile: None,
|
||
}),
|
||
});
|
||
ops.push(Op::DrawRectangle {
|
||
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
|
||
});
|
||
|
||
let normal_blend = if options.merge_lines {
|
||
let merge = doc.add_graphics_state(
|
||
ExtendedGraphicsState::default().with_blend_mode(BlendMode::multiply()),
|
||
);
|
||
let normal = doc.add_graphics_state(
|
||
ExtendedGraphicsState::default().with_blend_mode(BlendMode::normal()),
|
||
);
|
||
ops.push(Op::SaveGraphicsState);
|
||
ops.push(Op::LoadGraphicsState { gs: merge });
|
||
Some(normal)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Round line caps/joins for CAD aesthetics.
|
||
ops.push(Op::SetLineCapStyle {
|
||
cap: LineCapStyle::Round,
|
||
});
|
||
ops.push(Op::SetLineJoinStyle {
|
||
join: LineJoinStyle::Round,
|
||
});
|
||
|
||
// Apply rotation/scale/clip transform if needed.
|
||
// PDF uses mm-based coordinate system with origin at bottom-left.
|
||
// We save state, apply a CTM (+ optional clip path), then restore after drawing.
|
||
let needs_state = rotation_deg != 0 || (scale - 1.0).abs() > 1e-6 || clip.is_some();
|
||
if needs_state {
|
||
let (cos_a, sin_a, tx, ty) = match rotation_deg {
|
||
// `paper_w`/`paper_h` are already the effective, rotation-swapped
|
||
// page dimensions. A 90° turn maps x' = page_w - y, y' = x;
|
||
// 270° maps x' = y, y' = page_h - x.
|
||
90 => (0.0_f64, 1.0_f64, paper_w as f64, 0.0),
|
||
180 => (-1.0_f64, 0.0_f64, paper_w as f64, paper_h as f64),
|
||
270 => (0.0_f64, -1.0_f64, 0.0, paper_h as f64),
|
||
_ => (1.0_f64, 0.0_f64, 0.0, 0.0),
|
||
};
|
||
let s = scale as f64;
|
||
// PDF CTM: [a b c d e f] = [cos*s sin*s -sin*s cos*s tx ty]
|
||
ops.push(Op::SaveGraphicsState);
|
||
// Convert mm translation to points (1 mm = 2.834645 pt).
|
||
let tx_pt = (tx * 2.834645) as f32;
|
||
let ty_pt = (ty * 2.834645) as f32;
|
||
ops.push(Op::SetTransformationMatrix {
|
||
matrix: printpdf::CurTransMat::Raw([
|
||
(cos_a * s) as f32,
|
||
(sin_a * s) as f32,
|
||
(-(sin_a) * s) as f32,
|
||
(cos_a * s) as f32,
|
||
tx_pt,
|
||
ty_pt,
|
||
]),
|
||
});
|
||
// Clip rectangle (mm), applied in the pre-scale coordinate space so it
|
||
// matches the wires drawn under the same CTM.
|
||
if let Some((cx, cy, cw, ch)) = clip {
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: vec![PolygonRing {
|
||
points: vec![
|
||
LinePoint {
|
||
p: Point { x: Pt(cx * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
|
||
bezier: false,
|
||
},
|
||
LinePoint {
|
||
p: Point { x: Pt((cx + cw) * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
|
||
bezier: false,
|
||
},
|
||
LinePoint {
|
||
p: Point {
|
||
x: Pt((cx + cw) * MM_TO_PT),
|
||
y: Pt((cy + ch) * MM_TO_PT),
|
||
},
|
||
bezier: false,
|
||
},
|
||
LinePoint {
|
||
p: Point { x: Pt(cx * MM_TO_PT), y: Pt((cy + ch) * MM_TO_PT) },
|
||
bezier: false,
|
||
},
|
||
],
|
||
}],
|
||
mode: PaintMode::Clip,
|
||
winding_order: WindingOrder::NonZero,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
let (first_wires, second_wires) =
|
||
wires.split_at(group_splits.wires.min(wires.len()));
|
||
let (first_hatches, second_hatches) =
|
||
hatches.split_at(group_splits.hatches.min(hatches.len()));
|
||
let (first_wipeouts, second_wipeouts) =
|
||
wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len()));
|
||
let (first_images, second_images) =
|
||
images.split_at(group_splits.images.min(images.len()));
|
||
for (wires, hatches, wipeouts, images) in [
|
||
(first_wires, first_hatches, first_wipeouts, first_images),
|
||
(second_wires, second_hatches, second_wipeouts, second_images),
|
||
] {
|
||
enum DrawItem<'a> {
|
||
WireFill(&'a PlotWire),
|
||
Hatch(&'a HatchModel),
|
||
Image(&'a PlotImage),
|
||
Wire(&'a PlotWire),
|
||
Text(&'a PlotWire),
|
||
}
|
||
|
||
let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len());
|
||
let mut sequence = 0usize;
|
||
for wire in wires {
|
||
if !wire.fill_tris.is_empty() {
|
||
draw_items.push((wire.draw_depth, 0u8, sequence, DrawItem::WireFill(wire)));
|
||
sequence += 1;
|
||
}
|
||
draw_items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire)));
|
||
sequence += 1;
|
||
if !wire.text_verts.is_empty() || !wire.searchable_text.is_empty() {
|
||
draw_items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire)));
|
||
sequence += 1;
|
||
}
|
||
}
|
||
for hatch in wipeouts.iter().chain(hatches.iter()) {
|
||
draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch)));
|
||
sequence += 1;
|
||
}
|
||
for image in images {
|
||
draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image)));
|
||
sequence += 1;
|
||
}
|
||
// Everything outside the export window would only be clipped away, yet
|
||
// still written: a window over a corner of a large drawing came out as a
|
||
// blank page of several megabytes. Drop items whose ink misses the window.
|
||
if let Some((cx, cy, cw, ch)) = clip {
|
||
let marker_h = paper_h as f64 / scale.max(1e-6) as f64;
|
||
let window = [cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64];
|
||
draw_items.retain(|(_, _, _, item)| {
|
||
let bounds = match item {
|
||
DrawItem::WireFill(wire) | DrawItem::Wire(wire) | DrawItem::Text(wire) => {
|
||
wire_sheet_bounds(&wire.wire, ox, oy, marker_h)
|
||
}
|
||
DrawItem::Hatch(hatch) => hatch_sheet_bounds(hatch, ox, oy),
|
||
DrawItem::Image(_) => None,
|
||
};
|
||
bounds.is_none_or(|b| sheet_overlaps(b, window))
|
||
});
|
||
}
|
||
draw_items.sort_by(|a, b| {
|
||
a.0.total_cmp(&b.0)
|
||
.then_with(|| a.1.cmp(&b.1))
|
||
.then_with(|| a.2.cmp(&b.2))
|
||
});
|
||
|
||
let mut last_color: Option<[f32; 3]> = None;
|
||
let mut last_lw: Option<f32> = None;
|
||
let mut last_cap = Some(LineCapStyle::Round);
|
||
let mut last_join = Some(LineJoinStyle::Round);
|
||
// Current PDF dash array (empty = solid). Tracked so the dash op is only
|
||
// re-emitted when it actually changes between wires.
|
||
let mut last_dash: Option<Vec<i64>> = None;
|
||
|
||
for (_, _, _, item) in draw_items {
|
||
let wire = match item {
|
||
DrawItem::WireFill(wire) => {
|
||
emit_wire_fills(
|
||
&mut ops,
|
||
std::slice::from_ref(&wire.wire),
|
||
wire.draw_depth,
|
||
ox,
|
||
oy,
|
||
plot_style,
|
||
scale,
|
||
options,
|
||
normal_blend.as_ref(),
|
||
);
|
||
last_color = None;
|
||
last_lw = None;
|
||
last_dash = None;
|
||
continue;
|
||
}
|
||
DrawItem::Hatch(hatch) => {
|
||
emit_hatch(
|
||
&mut ops,
|
||
hatch,
|
||
ox,
|
||
oy,
|
||
plot_style,
|
||
scale,
|
||
options,
|
||
normal_blend.as_ref(),
|
||
);
|
||
last_color = None;
|
||
last_lw = None;
|
||
last_dash = None;
|
||
continue;
|
||
}
|
||
DrawItem::Text(wire) => {
|
||
// Outlined runs (stroke fonts, shaped/mixed scripts) keep
|
||
// their vector outlines and add an *invisible* (`Tr 3`) text
|
||
// layer on top for searching, copying and screen readers.
|
||
// A run that went out as embedded-font text is already
|
||
// searchable; only outlined runs need the invisible layer.
|
||
let embedded = emit_text(
|
||
&mut ops,
|
||
text_fonts,
|
||
std::slice::from_ref(&wire.wire),
|
||
ox,
|
||
oy,
|
||
scale,
|
||
plot_style,
|
||
options,
|
||
);
|
||
if !embedded {
|
||
emit_searchable_text(
|
||
&mut ops,
|
||
std::slice::from_ref(&wire.wire),
|
||
ox,
|
||
oy,
|
||
clip,
|
||
plot_style,
|
||
options,
|
||
fonts,
|
||
);
|
||
}
|
||
last_color = None;
|
||
last_lw = None;
|
||
last_dash = None;
|
||
continue;
|
||
}
|
||
DrawItem::Image(image) => {
|
||
emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?;
|
||
continue;
|
||
}
|
||
DrawItem::Wire(wire) => wire,
|
||
};
|
||
let [mut r, mut g, mut b, a] = wire.color;
|
||
if a < 0.01 {
|
||
continue;
|
||
}
|
||
// Skip screen-only paper helpers. The PDF page supplies its own white
|
||
// boundary, and the printable-area rectangle is a UI guide, not ink.
|
||
if matches!(wire.name.as_str(), "__paper_boundary__" | "paper_printable_area") {
|
||
continue;
|
||
}
|
||
// Apply CTB plot style table overrides (color + lineweight).
|
||
let mut lw_override: Option<f32> = None;
|
||
let mut screening = 1.0;
|
||
let mut color_overridden = false;
|
||
let mut cap = None;
|
||
let mut join = None;
|
||
if let Some(ctb) = plot_style {
|
||
if wire.aci > 0 {
|
||
if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) {
|
||
r = cr;
|
||
g = cg;
|
||
b = cb;
|
||
color_overridden = true;
|
||
}
|
||
lw_override = ctb
|
||
.resolve_lineweight(wire.aci)
|
||
.map(|mm| (mm * MM_TO_PT).max(0.1));
|
||
screening = ctb.resolve_screening(wire.aci);
|
||
if let Some(entry) = ctb.aci_entries.get(wire.aci as usize) {
|
||
cap = match entry.end_style {
|
||
0 => Some(LineCapStyle::Butt),
|
||
1 | 3 => Some(LineCapStyle::ProjectingSquare),
|
||
2 => Some(LineCapStyle::Round),
|
||
_ => None,
|
||
};
|
||
join = match entry.join_style {
|
||
0 => Some(LineJoinStyle::Miter),
|
||
1 | 3 => Some(LineJoinStyle::Bevel),
|
||
2 => Some(LineJoinStyle::Round),
|
||
_ => None,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
// Near-white and near-yellow (viewport active border) → dark grey for print
|
||
// (only when no CTB override was applied).
|
||
if !color_overridden {
|
||
// An authored white (not colour 7) plots as drawn, like on screen.
|
||
let is_light = r > 0.80
|
||
&& g > 0.80
|
||
&& b > 0.80
|
||
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
|
||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
|
||
if is_light || is_yellow {
|
||
r = 0.0;
|
||
g = 0.0;
|
||
b = 0.0;
|
||
} else if is_cyan {
|
||
// Viewport border: print as dark blue.
|
||
r = 0.0;
|
||
g = 0.15;
|
||
b = 0.50;
|
||
}
|
||
}
|
||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||
|
||
let cap = cap.unwrap_or(LineCapStyle::Round);
|
||
if last_cap != Some(cap) {
|
||
ops.push(Op::SetLineCapStyle { cap });
|
||
last_cap = Some(cap);
|
||
}
|
||
let join = join.unwrap_or(LineJoinStyle::Round);
|
||
if last_join != Some(join) {
|
||
ops.push(Op::SetLineJoinStyle { join });
|
||
last_join = Some(join);
|
||
}
|
||
|
||
if last_color
|
||
.map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01)
|
||
.unwrap_or(true)
|
||
{
|
||
let color = Color::Rgb(Rgb {
|
||
r,
|
||
g,
|
||
b,
|
||
icc_profile: None,
|
||
});
|
||
ops.push(Op::SetOutlineColor {
|
||
col: color.clone(),
|
||
});
|
||
ops.push(Op::SetFillColor { col: color });
|
||
last_color = Some([r, g, b]);
|
||
}
|
||
|
||
// Line weight: style override or object weight. Normal output divides
|
||
// by the page transform so physical pen widths stay constant; the
|
||
// scale-lineweights option deliberately keeps the transformed width.
|
||
//
|
||
// A wide polyline is the exception: its band is a geometric width in
|
||
// drawing units, so it must SCALE with the plot (no `/ scale`). Stroke
|
||
// the centre-line at `world_width`, converted mm → pt exactly like the
|
||
// geometry coordinates so the CTM scale renders the band at its true
|
||
// size; the linetype dash pattern below then strokes it dashed. This
|
||
// replaces the model-space hatch band that the shader-band change
|
||
// dropped, and overrides any CTB pen weight (the width is geometry, not
|
||
// a lineweight).
|
||
let pen_divisor = if options.scale_lineweights {
|
||
1.0
|
||
} else {
|
||
scale.max(1e-6)
|
||
};
|
||
let lw_pt = if wire.world_width > 0.0 {
|
||
wire.world_width * MM_TO_PT
|
||
} else {
|
||
let physical = if options.object_lineweights {
|
||
lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1))
|
||
} else {
|
||
0.1
|
||
};
|
||
physical / pen_divisor
|
||
};
|
||
if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) {
|
||
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
|
||
last_lw = Some(lw_pt);
|
||
}
|
||
|
||
// Linetype dash pattern. Without this every wire exported as a solid
|
||
// line regardless of its linetype (dashed / centre / dash-dot). (#155)
|
||
let dash_arr = dash_array_from_pattern(wire.pattern_length, &wire.pattern, MM_TO_PT);
|
||
let stationed =
|
||
!dash_arr.is_empty() && wire.pattern_stations.len() > wire.points.len();
|
||
if stationed {
|
||
if last_dash.as_ref().is_none_or(|dash| !dash.is_empty()) {
|
||
ops.push(Op::SetLineDashPattern {
|
||
dash: LineDashPattern::default(),
|
||
});
|
||
last_dash = Some(Vec::new());
|
||
}
|
||
for index in 0..wire.points.len().saturating_sub(1) {
|
||
if !wire.points[index][0].is_finite()
|
||
|| !wire.points[index + 1][0].is_finite()
|
||
{
|
||
continue;
|
||
}
|
||
let start = wire.point_world(index, paper_h as f64 / scale.max(1e-6) as f64);
|
||
let end = wire.point_world(index + 1, paper_h as f64 / scale.max(1e-6) as f64);
|
||
for [from, to] in visible_station_ranges(
|
||
wire.pattern_stations[index],
|
||
wire.pattern_stations[index + 1],
|
||
wire.pattern_length,
|
||
&wire.pattern,
|
||
) {
|
||
let point = |t: f32| {
|
||
LinePoint {
|
||
p: Point::new(
|
||
Mm((start.x + (end.x - start.x) * t as f64 + ox) as f32),
|
||
Mm((start.y + (end.y - start.y) * t as f64 + oy) as f32),
|
||
),
|
||
bezier: false,
|
||
}
|
||
};
|
||
flush_line(&mut ops, &[point(from), point(to)], None);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if last_dash.as_deref() != Some(dash_arr.as_slice()) {
|
||
let dash = if dash_arr.is_empty() {
|
||
LineDashPattern::default()
|
||
} else {
|
||
LineDashPattern::from_array(&dash_arr, 0)
|
||
};
|
||
ops.push(Op::SetLineDashPattern { dash });
|
||
last_dash = Some(dash_arr.clone());
|
||
}
|
||
|
||
// Emit segments (NaN = pen-up). Points are the "high" half of a
|
||
// double-single pair; fold in the `points_low` residual and cancel the
|
||
// offset in f64 before narrowing. Dropping the residual (or narrowing
|
||
// first) snaps a UTM drawing onto the f32 grid — ~3 cm across, ~50 cm
|
||
// along northing — which is exactly the distortion the plot showed while
|
||
// low-coordinate drawings came out clean. The result is a sheet-mm value
|
||
// in single digits, so f32 is lossless from here.
|
||
let mut segment: Vec<LinePoint> = Vec::new();
|
||
let dot_radius = (wire.name == "viewport_hatch_pattern")
|
||
.then_some(Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6))));
|
||
for (pi, &[x, y, _z]) in wire.points.iter().enumerate() {
|
||
if x.is_nan() || y.is_nan() {
|
||
flush_line(&mut ops, &segment, dot_radius);
|
||
segment.clear();
|
||
} else {
|
||
let point = wire.point_world(pi, paper_h as f64 / scale.max(1e-6) as f64);
|
||
let wx = (point.x + ox) as f32;
|
||
let wy = (point.y + oy) as f32;
|
||
segment.push(LinePoint {
|
||
p: Point::new(Mm(wx), Mm(wy)),
|
||
bezier: false,
|
||
});
|
||
}
|
||
}
|
||
flush_line(&mut ops, &segment, dot_radius);
|
||
}
|
||
}
|
||
|
||
if needs_state {
|
||
ops.push(Op::RestoreGraphicsState);
|
||
}
|
||
if options.merge_lines {
|
||
ops.push(Op::RestoreGraphicsState);
|
||
}
|
||
if options.stamp {
|
||
emit_plot_stamp(&mut ops);
|
||
}
|
||
|
||
let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops);
|
||
doc.pages.push(page);
|
||
Ok(())
|
||
}
|
||
|
||
/// Slack around the export window, in sheet mm, so a stroke whose centreline
|
||
/// sits just outside still contributes its width.
|
||
const CULL_MARGIN_MM: f64 = 10.0;
|
||
|
||
fn sheet_overlaps(b: [f64; 4], window: [f64; 4]) -> bool {
|
||
b[0] <= window[2] + CULL_MARGIN_MM
|
||
&& b[2] >= window[0] - CULL_MARGIN_MM
|
||
&& b[1] <= window[3] + CULL_MARGIN_MM
|
||
&& b[3] >= window[1] - CULL_MARGIN_MM
|
||
}
|
||
|
||
fn grow(bounds: &mut Option<[f64; 4]>, x: f64, y: f64) {
|
||
if !x.is_finite() || !y.is_finite() {
|
||
return;
|
||
}
|
||
let b = bounds.get_or_insert([x, y, x, y]);
|
||
b[0] = b[0].min(x);
|
||
b[1] = b[1].min(y);
|
||
b[2] = b[2].max(x);
|
||
b[3] = b[3].max(y);
|
||
}
|
||
|
||
/// Sheet-mm bounds of everything a wire inks (strokes, glyphs, fills).
|
||
fn wire_sheet_bounds(wire: &WireModel, ox: f64, oy: f64, marker_h: f64) -> Option<[f64; 4]> {
|
||
let mut bounds = None;
|
||
for (index, point) in wire.points.iter().enumerate() {
|
||
if point[0].is_finite() && point[1].is_finite() {
|
||
let world = wire.point_world(index, marker_h);
|
||
grow(&mut bounds, world.x + ox, world.y + oy);
|
||
}
|
||
}
|
||
for vertex in &wire.text_verts {
|
||
let [x, y] = glyph_world_xy(vertex);
|
||
grow(&mut bounds, x + ox, y + oy);
|
||
}
|
||
for (index, point) in wire.fill_tris.iter().enumerate() {
|
||
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
grow(
|
||
&mut bounds,
|
||
point[0] as f64 + low[0] as f64 + ox,
|
||
point[1] as f64 + low[1] as f64 + oy,
|
||
);
|
||
}
|
||
bounds
|
||
}
|
||
|
||
/// Sheet-mm bounds of a hatch, resolved the way `emit_hatch` places it.
|
||
fn hatch_sheet_bounds(hatch: &HatchModel, ox: f64, oy: f64) -> Option<[f64; 4]> {
|
||
let mut bounds = None;
|
||
for &[x, y] in hatch.boundary.iter() {
|
||
grow(
|
||
&mut bounds,
|
||
x as f64 + hatch.world_origin[0] + ox,
|
||
y as f64 + hatch.world_origin[1] + oy,
|
||
);
|
||
}
|
||
bounds
|
||
}
|
||
|
||
/// Build a PDF dash array (in points) from a WireModel linetype pattern.
|
||
///
|
||
/// `pattern` holds the linetype run lengths in paper-mm: positive = dash,
|
||
/// negative = gap, exactly 0 = a dot, and trailing zeros are padding — so the
|
||
/// real length is the index of the last non-zero element + 1 (same convention
|
||
/// the wire shader uses). Returns an empty vec for a solid line. printpdf's
|
||
/// `LineDashPattern` holds at most six entries, so longer patterns are
|
||
/// truncated to three dash/gap pairs.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f32) -> Vec<i64> {
|
||
if pattern_length <= 1e-6 {
|
||
return Vec::new();
|
||
}
|
||
let count = match pattern.iter().rposition(|&v| v != 0.0) {
|
||
Some(i) => (i + 1).min(6),
|
||
None => return Vec::new(),
|
||
};
|
||
pattern[..count]
|
||
.iter()
|
||
// Round to whole points (printpdf dash entries are integers) and keep a
|
||
// 1 pt floor so a zero-length dot still prints as a short mark.
|
||
.map(|&v| (((v.abs() * mm_to_pt).round()) as i64).max(1))
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn visible_station_ranges(
|
||
start: f32,
|
||
end: f32,
|
||
pattern_length: f32,
|
||
pattern: &[f32; 8],
|
||
) -> Vec<[f32; 2]> {
|
||
let count = pattern.iter().rposition(|value| *value != 0.0).map_or(0, |i| i + 1);
|
||
if count == 0 || pattern_length <= 1e-6 {
|
||
return vec![[0.0, 1.0]];
|
||
}
|
||
let dot = 1.0 / MM_TO_PT;
|
||
let mut elements: Vec<(f32, bool)> = pattern[..count]
|
||
.iter()
|
||
.map(|value| (if *value == 0.0 { dot } else { value.abs() }, *value >= 0.0))
|
||
.collect();
|
||
let total: f32 = elements.iter().map(|(length, _)| *length).sum();
|
||
if total <= 1e-6 {
|
||
return vec![[0.0, 1.0]];
|
||
}
|
||
let factor = pattern_length / total;
|
||
for (length, _) in &mut elements {
|
||
*length *= factor;
|
||
}
|
||
let delta = end - start;
|
||
let state = |station: f32, forward: bool| {
|
||
let mut phase = station.rem_euclid(pattern_length);
|
||
if !forward && phase <= 1e-6 {
|
||
phase = pattern_length;
|
||
}
|
||
let mut offset = 0.0;
|
||
if forward {
|
||
for &(length, drawn) in &elements {
|
||
let end = offset + length;
|
||
if phase < end - 1e-6 {
|
||
return (drawn, end - phase);
|
||
}
|
||
offset = end;
|
||
}
|
||
(elements[0].1, elements[0].0)
|
||
} else {
|
||
offset = pattern_length;
|
||
for &(length, drawn) in elements.iter().rev() {
|
||
offset -= length;
|
||
if phase > offset + 1e-6 {
|
||
return (drawn, phase - offset);
|
||
}
|
||
}
|
||
let last = elements[elements.len() - 1];
|
||
(last.1, last.0)
|
||
}
|
||
};
|
||
if delta.abs() <= 1e-6 {
|
||
return state(start, true).0.then_some([0.0, 1.0]).into_iter().collect();
|
||
}
|
||
|
||
let mut ranges = Vec::new();
|
||
let mut t = 0.0;
|
||
while t < 1.0 - 1e-6 {
|
||
let station = start + delta * t;
|
||
let (drawn, remaining) = state(station, delta > 0.0);
|
||
let next = (t + remaining / delta.abs()).clamp(t + 1e-6, 1.0);
|
||
if drawn {
|
||
ranges.push([t, next]);
|
||
}
|
||
t = next;
|
||
}
|
||
ranges
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint], dot_radius: Option<Pt>) {
|
||
if pts.len() < 2 {
|
||
return;
|
||
}
|
||
if let Some(radius) = dot_radius {
|
||
let first = pts[0].p;
|
||
let coincident = pts.iter().skip(1).all(|point| {
|
||
(point.p.x.0 - first.x.0).abs() <= 1e-6
|
||
&& (point.p.y.0 - first.y.0).abs() <= 1e-6
|
||
});
|
||
if coincident {
|
||
emit_round_dot(ops, first, radius);
|
||
return;
|
||
}
|
||
}
|
||
ops.push(Op::DrawLine {
|
||
line: Line {
|
||
points: pts.to_vec(),
|
||
is_closed: false,
|
||
},
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_round_dot(ops: &mut Vec<Op>, center: Point, radius: Pt) {
|
||
const SIDES: usize = 12;
|
||
let points = (0..SIDES)
|
||
.map(|index| {
|
||
let angle = std::f32::consts::TAU * index as f32 / SIDES as f32;
|
||
LinePoint {
|
||
p: Point {
|
||
x: Pt(center.x.0 + radius.0 * angle.cos()),
|
||
y: Pt(center.y.0 + radius.0 * angle.sin()),
|
||
},
|
||
bezier: false,
|
||
}
|
||
})
|
||
.collect();
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: vec![PolygonRing { points }],
|
||
mode: PaintMode::Fill,
|
||
winding_order: WindingOrder::NonZero,
|
||
},
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn plotted_color(
|
||
rgb: [f32; 3],
|
||
alpha: f32,
|
||
screening: f32,
|
||
options: PdfPlotOptions,
|
||
) -> [f32; 3] {
|
||
let amount = screening.clamp(0.0, 1.0)
|
||
* if options.transparency {
|
||
alpha.clamp(0.0, 1.0)
|
||
} else {
|
||
1.0
|
||
};
|
||
[
|
||
1.0 - (1.0 - rgb[0]) * amount,
|
||
1.0 - (1.0 - rgb[1]) * amount,
|
||
1.0 - (1.0 - rgb[2]) * amount,
|
||
]
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_wire_fills(
|
||
ops: &mut Vec<Op>,
|
||
wires: &[WireModel],
|
||
wire_depth: f32,
|
||
ox: f64,
|
||
oy: f64,
|
||
plot_style: Option<&PlotStyleTable>,
|
||
scale: f32,
|
||
options: PdfPlotOptions,
|
||
normal_blend: Option<&ExtendedGraphicsStateId>,
|
||
) {
|
||
for wire in wires {
|
||
if wire.fill_tris.is_empty() {
|
||
continue;
|
||
}
|
||
let styled_pattern = plot_style.and_then(|table| {
|
||
(wire.aci > 0)
|
||
.then(|| table.aci_entries.get(wire.aci as usize))
|
||
.flatten()
|
||
.and_then(|entry| {
|
||
(65..=72)
|
||
.contains(&entry.fill_style)
|
||
.then(|| {
|
||
crate::scene::model::hatch_model::plot_style_fill_pattern(
|
||
entry.fill_style,
|
||
)
|
||
})
|
||
.flatten()
|
||
})
|
||
});
|
||
if let Some(pattern) = styled_pattern {
|
||
for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() {
|
||
let mut boundary = Vec::with_capacity(4);
|
||
for (point_index, point) in triangle.iter().enumerate() {
|
||
let index = triangle_index * 3 + point_index;
|
||
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
boundary.push([point[0] + low[0], point[1] + low[1]]);
|
||
}
|
||
boundary.push(boundary[0]);
|
||
let hatch = HatchModel {
|
||
pattern_origin: None,
|
||
render_instance: wire.render_instance.clone(),
|
||
world_origin: [0.0, 0.0],
|
||
boundary: std::sync::Arc::new(boundary),
|
||
boundary_wcs: None,
|
||
fill_plane: None,
|
||
fill_plane_boundary: None,
|
||
boundary_exterior: None,
|
||
boundary_sources: None,
|
||
boundary_paths: None,
|
||
style: codec::entities::HatchStyleType::Normal,
|
||
pattern: pattern.clone(),
|
||
name: "PLOTSTYLE".to_string(),
|
||
color: wire.color,
|
||
aci: wire.aci,
|
||
line_weight_px: wire.line_weight_px,
|
||
angle_offset: 0.0,
|
||
scale: 1.0 / scale.max(1.0e-6),
|
||
// The host wire's composed draw depth (PlotWire carries
|
||
// wire_draw_depth). depth_override alone is a per-block
|
||
// child label and would sort the fill outside its block's
|
||
// band; keep the pattern fill co-sorted with its wire.
|
||
draw_depth: wire_depth,
|
||
};
|
||
emit_hatch(
|
||
ops,
|
||
&hatch,
|
||
ox,
|
||
oy,
|
||
plot_style,
|
||
scale,
|
||
options,
|
||
normal_blend,
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
let [mut r, mut g, mut b, a] = wire.color;
|
||
if a < 0.01 {
|
||
continue;
|
||
}
|
||
if wire.bg_adapt.as_deref().is_some_and(|adapt| adapt.canvas_color) {
|
||
// A background mask: it covers with the paper itself.
|
||
[r, g, b] = [1.0, 1.0, 1.0];
|
||
} else {
|
||
let mut screening = 1.0;
|
||
let mut color_overridden = false;
|
||
if let Some(table) = plot_style {
|
||
if wire.aci > 0 {
|
||
if let Some(color) = table.resolve_color(wire.aci) {
|
||
[r, g, b] = color;
|
||
color_overridden = true;
|
||
}
|
||
screening = table.resolve_screening(wire.aci);
|
||
}
|
||
}
|
||
if !color_overridden {
|
||
[r, g, b] = adapt_text_color([r, g, b]);
|
||
}
|
||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||
}
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb {
|
||
r,
|
||
g,
|
||
b,
|
||
icc_profile: None,
|
||
}),
|
||
});
|
||
for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() {
|
||
let mut points = Vec::with_capacity(3);
|
||
for (point_index, &[x, y, _]) in triangle.iter().enumerate() {
|
||
let index = triangle_index * 3 + point_index;
|
||
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
points.push(LinePoint {
|
||
p: Point::new(
|
||
Mm((x as f64 + low[0] as f64 + ox) as f32),
|
||
Mm((y as f64 + low[1] as f64 + oy) as f32),
|
||
),
|
||
bezier: false,
|
||
});
|
||
}
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: vec![PolygonRing { points }],
|
||
mode: PaintMode::Fill,
|
||
winding_order: WindingOrder::NonZero,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_plot_stamp(ops: &mut Vec<Op>) {
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0);
|
||
let user = std::env::var("USER")
|
||
.or_else(|_| std::env::var("USERNAME"))
|
||
.unwrap_or_else(|_| "user".into());
|
||
let label = format!("Open CAD Studio | {user} | {timestamp}");
|
||
ops.extend([
|
||
Op::SaveGraphicsState,
|
||
Op::StartTextSection,
|
||
Op::SetTextCursor {
|
||
pos: Point::new(Mm(4.0), Mm(3.0)),
|
||
},
|
||
Op::SetFont {
|
||
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
|
||
size: Pt(6.0),
|
||
},
|
||
Op::SetFillColor {
|
||
col: Color::Rgb(Rgb {
|
||
r: 0.25,
|
||
g: 0.25,
|
||
b: 0.25,
|
||
icc_profile: None,
|
||
}),
|
||
},
|
||
Op::ShowText {
|
||
items: vec![TextItem::Text(label)],
|
||
},
|
||
Op::EndTextSection,
|
||
Op::RestoreGraphicsState,
|
||
]);
|
||
}
|
||
|
||
/// Emit a single hatch / wipeout as a filled (or stroked, for pattern fills)
|
||
/// polygon. NaN sentinels in `hatch.boundary` split the path into multiple
|
||
/// rings so islands and holes render correctly under the even-odd rule.
|
||
/// Mirrors `scene::paper_canvas::draw_hatch`: solid → fill, pattern → outline,
|
||
/// gradient → solid fill of the averaged colour.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_hatch(
|
||
ops: &mut Vec<Op>,
|
||
hatch: &HatchModel,
|
||
ox: f64,
|
||
oy: f64,
|
||
plot_style: Option<&PlotStyleTable>,
|
||
scale: f32,
|
||
options: PdfPlotOptions,
|
||
normal_blend: Option<&ExtendedGraphicsStateId>,
|
||
) {
|
||
if hatch.boundary.is_empty() {
|
||
return;
|
||
}
|
||
let mut styled_hatch = None;
|
||
if let Some(table) = plot_style {
|
||
if hatch.aci > 0 && matches!(hatch.pattern, HatchPattern::Solid) {
|
||
if let Some(pattern) = table
|
||
.aci_entries
|
||
.get(hatch.aci as usize)
|
||
.and_then(|entry| {
|
||
crate::scene::model::hatch_model::plot_style_fill_pattern(
|
||
entry.fill_style,
|
||
)
|
||
})
|
||
{
|
||
let mut model = hatch.clone();
|
||
model.pattern = pattern;
|
||
model.scale = 1.0 / scale.max(1.0e-6);
|
||
styled_hatch = Some(model);
|
||
}
|
||
}
|
||
}
|
||
let hatch = styled_hatch.as_ref().unwrap_or(hatch);
|
||
let [mut r, mut g, mut b, a] = hatch.color;
|
||
if a < 0.01 {
|
||
return;
|
||
}
|
||
// Adapt hatch fills to the white sheet, mirroring the wire pass: colours
|
||
// arrive adapted to the (dark) screen background, so a white/ACI-7 fill
|
||
// would vanish white-on-white on paper. Force near-white/near-yellow → black
|
||
// and near-cyan → dark blue for a readable white-sheet result.
|
||
// Genuine colours are untouched; WIPEOUTS keep their paper-white mask.
|
||
let is_wipeout = hatch.name == "WIPEOUT_FILL";
|
||
let mut screening = 1.0;
|
||
let mut lw_override = None;
|
||
let mut color_overridden = false;
|
||
if !is_wipeout {
|
||
if let Some(table) = plot_style {
|
||
if hatch.aci > 0 {
|
||
if let Some([cr, cg, cb]) = table.resolve_color(hatch.aci) {
|
||
r = cr;
|
||
g = cg;
|
||
b = cb;
|
||
color_overridden = true;
|
||
}
|
||
screening = table.resolve_screening(hatch.aci);
|
||
lw_override = table
|
||
.resolve_lineweight(hatch.aci)
|
||
.map(|mm| (mm * MM_TO_PT).max(0.1));
|
||
}
|
||
}
|
||
}
|
||
if is_wipeout {
|
||
// Screen wipeouts match the configured canvas colour; printed
|
||
// wipeouts must mask with the white paper colour.
|
||
r = 1.0;
|
||
g = 1.0;
|
||
b = 1.0;
|
||
} else if !color_overridden
|
||
// `aci == 0` is an explicit true colour: it plots as drawn, only
|
||
// indexed colours meant for the dark screen are adapted. (#1417)
|
||
&& hatch.aci != 0
|
||
&& !(hatch.aci == 7 && matches!(hatch.pattern, HatchPattern::Solid))
|
||
{
|
||
// An authored white (not colour 7) plots as drawn, like on screen.
|
||
let is_light = r > 0.80
|
||
&& g > 0.80
|
||
&& b > 0.80
|
||
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
|
||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
|
||
if is_light || is_yellow {
|
||
r = 0.0;
|
||
g = 0.0;
|
||
b = 0.0;
|
||
} else if is_cyan {
|
||
r = 0.0;
|
||
g = 0.15;
|
||
b = 0.50;
|
||
}
|
||
}
|
||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||
// `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the
|
||
// pair in f64 and only narrow once the offset has cancelled — casting
|
||
// `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the
|
||
// boundary-relative encoding exists to avoid.
|
||
let (world_ox, world_oy) = (hatch.world_origin[0], hatch.world_origin[1]);
|
||
|
||
// Split the boundary into rings on every NaN-NaN separator.
|
||
let mut rings: Vec<PolygonRing> = Vec::new();
|
||
let mut current: Vec<LinePoint> = Vec::new();
|
||
for &[bx, by] in hatch.boundary.iter() {
|
||
if bx.is_nan() || by.is_nan() {
|
||
if current.len() >= 3 {
|
||
rings.push(PolygonRing { points: std::mem::take(&mut current) });
|
||
} else {
|
||
current.clear();
|
||
}
|
||
continue;
|
||
}
|
||
let px = (bx as f64 + world_ox + ox) as f32;
|
||
let py = (by as f64 + world_oy + oy) as f32;
|
||
current.push(LinePoint {
|
||
p: Point::new(Mm(px), Mm(py)),
|
||
bezier: false,
|
||
});
|
||
}
|
||
if current.len() >= 3 {
|
||
rings.push(PolygonRing { points: current });
|
||
}
|
||
if rings.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let (paint_mode, fill_color) = match &hatch.pattern {
|
||
HatchPattern::Solid => (PaintMode::Fill, [r, g, b]),
|
||
HatchPattern::Pattern(_) => {
|
||
// Pattern fills are emitted as raster line segments below; the
|
||
// outline polygon path itself is skipped because pattern
|
||
// hatches in real DXF do not draw their boundary as part of
|
||
// the fill.
|
||
(PaintMode::Clip, [r, g, b]) // sentinel — handled below
|
||
}
|
||
HatchPattern::Gradient { color2, .. } => {
|
||
// PDF gradients are stored in resource dictionaries; for the
|
||
// fast path we average the two colours, matching paper_canvas.
|
||
let second = if color_overridden {
|
||
[r, g, b]
|
||
} else {
|
||
plotted_color(
|
||
adapt_text_color([color2[0], color2[1], color2[2]]),
|
||
color2[3],
|
||
screening,
|
||
options,
|
||
)
|
||
};
|
||
let avg = [
|
||
(r + second[0]) * 0.5,
|
||
(g + second[1]) * 0.5,
|
||
(b + second[2]) * 0.5,
|
||
];
|
||
(PaintMode::Fill, avg)
|
||
}
|
||
};
|
||
|
||
// Pattern hatches: rasterise the family lines clipped to the boundary
|
||
// and emit each as a stroked line. Skips the polygon outline entirely.
|
||
if matches!(hatch.pattern, HatchPattern::Pattern(_)) {
|
||
let physical = if options.object_lineweights {
|
||
lw_override.unwrap_or_else(|| (hatch.line_weight_px * LW_PX_TO_PT).max(0.1))
|
||
} else {
|
||
0.1
|
||
};
|
||
let divisor = if options.scale_lineweights {
|
||
1.0
|
||
} else {
|
||
scale.max(1e-6)
|
||
};
|
||
let segments = hatch.pattern_segments_for_plot();
|
||
if segments.is_empty() {
|
||
return;
|
||
}
|
||
let color = Color::Rgb(Rgb {
|
||
r,
|
||
g,
|
||
b,
|
||
icc_profile: None,
|
||
});
|
||
ops.push(Op::SetOutlineColor {
|
||
col: color.clone(),
|
||
});
|
||
ops.push(Op::SetFillColor { col: color });
|
||
ops.push(Op::SetOutlineThickness {
|
||
pt: Pt(physical / divisor),
|
||
});
|
||
// Pattern dashes are already materialized by `pattern_segments`.
|
||
// Clear any linetype left by the preceding paper/model render group.
|
||
ops.push(Op::SetLineDashPattern {
|
||
dash: LineDashPattern::default(),
|
||
});
|
||
for [a, b_pt] in segments {
|
||
// `pattern_segments` returns absolute world f64; cancel the offset
|
||
// before narrowing, as everywhere else in this file.
|
||
let (ax, ay) = ((a[0] + ox) as f32, (a[1] + oy) as f32);
|
||
let (bx, by) = ((b_pt[0] + ox) as f32, (b_pt[1] + oy) as f32);
|
||
let points = vec![
|
||
LinePoint {
|
||
p: Point::new(Mm(ax), Mm(ay)),
|
||
bezier: false,
|
||
},
|
||
LinePoint {
|
||
p: Point::new(Mm(bx), Mm(by)),
|
||
bezier: false,
|
||
},
|
||
];
|
||
let dot_radius = Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6)));
|
||
flush_line(ops, &points, Some(dot_radius));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Solid / gradient: filled polygon path.
|
||
if matches!(paint_mode, PaintMode::Fill | PaintMode::FillStroke) {
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb {
|
||
r: fill_color[0],
|
||
g: fill_color[1],
|
||
b: fill_color[2],
|
||
icc_profile: None,
|
||
}),
|
||
});
|
||
}
|
||
if is_wipeout {
|
||
if let Some(gs) = normal_blend {
|
||
ops.push(Op::SaveGraphicsState);
|
||
ops.push(Op::LoadGraphicsState { gs: gs.clone() });
|
||
}
|
||
}
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings,
|
||
mode: paint_mode,
|
||
winding_order: WindingOrder::EvenOdd,
|
||
},
|
||
});
|
||
if is_wipeout && normal_blend.is_some() {
|
||
ops.push(Op::RestoreGraphicsState);
|
||
}
|
||
}
|
||
|
||
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
|
||
|
||
/// Absolute world XY of a glyph vertex (double-single high + low parts folded).
|
||
///
|
||
/// The fold must happen in f64: the pair exists because the absolute coordinate
|
||
/// does not fit an f32, so `pos + pos_low` evaluated in f32 rounds straight back
|
||
/// to `pos` and throws away the residual it was carrying.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2] {
|
||
[
|
||
v.pos[0] as f64 + v.pos_low[0] as f64,
|
||
v.pos[1] as f64 + v.pos_low[1] as f64,
|
||
]
|
||
}
|
||
|
||
/// Adapt a text colour to the white sheet, mirroring the wire/hatch passes:
|
||
/// near-white / near-yellow (colour-7-on-white) → black, near-cyan → dark blue.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||
// An authored white (not colour 7) plots as drawn, like on screen.
|
||
let is_light = r > 0.80
|
||
&& g > 0.80
|
||
&& b > 0.80
|
||
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
|
||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
|
||
if is_light || is_yellow {
|
||
[0.0, 0.0, 0.0]
|
||
} else if is_cyan {
|
||
[0.0, 0.15, 0.50]
|
||
} else {
|
||
[r, g, b]
|
||
}
|
||
}
|
||
|
||
/// Invisible searchable text layer for laid-out runs (`Tr 3`).
|
||
///
|
||
/// Each `SearchableTextRun` carries the visible string plus its final
|
||
/// world-space baseline origin, cap height, advance width and rotation — all
|
||
/// baked by the layout stage (MTEXT `\P` newlines arrive as separate per-line
|
||
/// runs, `attachment_point`/width wrapping and block placement already
|
||
/// applied), so the exporter only places a `BT … Tj ET` with an
|
||
/// arbitrary-rotation text matrix (`Tm`), honouring the MTEXT traps by
|
||
/// construction.
|
||
///
|
||
/// Design (review V2): the vector outlines are **always** drawn — the plot
|
||
/// looks pixel-identical with or without this layer — and the runs add an
|
||
/// invisible (`Tr 3`) text layer for searching, copying and screen readers.
|
||
/// A gap here can only affect extractability, never the rendered sheet.
|
||
/// Decorations (underline/overline/strike), stroke fonts, shaped and mixed
|
||
/// scripts keep working because their outlines are untouched.
|
||
///
|
||
/// Font choice per run (review V3 — "why Helvetica, not the DWG font?"):
|
||
/// - A run whose style resolves to an installed TrueType family is set in a
|
||
/// **subset CIDFontType2 of that exact family** (+ `/ToUnicode`), cut down
|
||
/// by `allsorts` to the glyphs the plot uses. Bold runs resolve the bold
|
||
/// face via fontdb weight matching.
|
||
/// - SHX/LFF stroke fonts have no embeddable font program (there is no TrueType
|
||
/// behind `txt`/`romans` — only pen strokes), unresolvable names and CFF/
|
||
/// collection edge cases fall back to Base-14 Helvetica/Helvetica-Bold.
|
||
/// A Type3 font synthesized from the SDF atlas would cover those exactly and
|
||
/// is the natural follow-up; it needs no new layout data.
|
||
/// - `Tf` takes an *em* size but `run.height` is a *cap* height: the em size
|
||
/// is `height / cap_ratio` (per-font OS/2 sCapHeight, 0.72 for Helvetica) so
|
||
/// the invisible run aligns with the drawn cap height.
|
||
/// - Width-matching (`Tz`, baked into the `Tm` x-basis — equivalent, and the
|
||
/// only horizontal scaling printpdf exposes): `k = laid_advance /
|
||
/// natural_advance` from the run's stored pen advance and the font's hmtx
|
||
/// advances, so centered/right-aligned runs highlight where they draw.
|
||
/// - Scaling: the font size is converted world-mm → points via `MM_TO_PT`;
|
||
/// the page CTM (plot scale/rotation/clip) applies on top inside the same
|
||
/// graphics state as the wire geometry, so text and outlines scale together.
|
||
/// - Runs fully outside the export-window clip are skipped (rect overlap on
|
||
/// the run rect — same space as the clip polygon).
|
||
///
|
||
/// Returns the number of runs emitted.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_searchable_text(
|
||
ops: &mut Vec<Op>,
|
||
wires: &[WireModel],
|
||
ox: f64,
|
||
oy: f64,
|
||
clip: Option<(f32, f32, f32, f32)>,
|
||
plot_style: Option<&PlotStyleTable>,
|
||
options: PdfPlotOptions,
|
||
fonts: &SearchableFontSet,
|
||
) -> usize {
|
||
use crate::scene::model::wire_model::run_rect_overlap;
|
||
/// Helvetica cap height ≈ 0.72 em.
|
||
const HELVETICA_CAP_RATIO: f32 = 0.72;
|
||
let mut emitted = 0;
|
||
for wire in wires {
|
||
if wire.searchable_text.is_empty() {
|
||
continue;
|
||
}
|
||
let mut ctb_color: Option<[f32; 3]> = None;
|
||
let mut screening = 1.0;
|
||
if let Some(ctb) = plot_style {
|
||
if wire.aci > 0 {
|
||
ctb_color = ctb.resolve_color(wire.aci);
|
||
screening = ctb.resolve_screening(wire.aci);
|
||
}
|
||
}
|
||
for run in &wire.searchable_text {
|
||
if run.text.is_empty() || run.height <= 0.0 || !run.height.is_finite() {
|
||
continue;
|
||
}
|
||
// Export-window clip culls by run rect (origin + advance × cap
|
||
// height box); the survivors inherit the clip path anyway.
|
||
if let Some((cx, cy, cw, ch)) = clip {
|
||
let px = run.origin[0] + ox;
|
||
let py = run.origin[1] + oy;
|
||
if !run_rect_overlap(
|
||
[px, py],
|
||
run.rotation,
|
||
run.adv_width as f64,
|
||
run.height as f64,
|
||
[cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64],
|
||
) {
|
||
continue;
|
||
}
|
||
}
|
||
// Per-run font choice: the DWG style's own family when a subset
|
||
// is embedded, else the WinAnsi-gated Helvetica fallback.
|
||
enum RunFont<'a> {
|
||
Subset(&'a EmbeddedSubset),
|
||
Helvetica,
|
||
}
|
||
let choice = match crate::scene::text::font_face::Face::resolve(&run.font) {
|
||
crate::scene::text::font_face::Face::Ttf { family, .. } => fonts
|
||
.find(&family, run.bold)
|
||
.map(RunFont::Subset)
|
||
.unwrap_or(RunFont::Helvetica),
|
||
_ => RunFont::Helvetica,
|
||
};
|
||
if matches!(choice, RunFont::Helvetica) && !is_winansi_encodable(&run.text) {
|
||
continue;
|
||
}
|
||
let rgb = ctb_color.unwrap_or_else(|| adapt_text_color([run.color[0], run.color[1], run.color[2]]));
|
||
let [r, g, b] = plotted_color(rgb, run.color[3], screening, options);
|
||
// World mm → page points; the page CTM (plot scale/rotation/clip)
|
||
// applies on top, exactly as for the wire geometry.
|
||
let x_pt = ((run.origin[0] + ox) * MM_TO_PT as f64) as f32;
|
||
let y_pt = ((run.origin[1] + oy) * MM_TO_PT as f64) as f32;
|
||
if !x_pt.is_finite() || !y_pt.is_finite() {
|
||
continue;
|
||
}
|
||
// Em size from cap height, and width-match factor baked into the
|
||
// Tm x-basis (= Tz, the only horizontal scaling printpdf exposes).
|
||
let (font_handle, size_pt, width_k) = match choice {
|
||
RunFont::Subset(sub) => {
|
||
let size = (run.height * MM_TO_PT / sub.cap_ratio).max(0.1);
|
||
(PdfFontHandle::External(sub.font_id.clone()), size, sub.width_factor(run))
|
||
}
|
||
RunFont::Helvetica => {
|
||
let size = (run.height * MM_TO_PT / HELVETICA_CAP_RATIO).max(0.1);
|
||
let handle = PdfFontHandle::Builtin(if run.bold {
|
||
BuiltinFont::HelveticaBold
|
||
} else {
|
||
BuiltinFont::Helvetica
|
||
});
|
||
(handle, size, 1.0)
|
||
}
|
||
};
|
||
if !size_pt.is_finite() {
|
||
continue;
|
||
}
|
||
let (sin_r, cos_r) = run.rotation.sin_cos();
|
||
ops.push(Op::StartTextSection);
|
||
ops.push(Op::SetFont { font: font_handle, size: Pt(size_pt) });
|
||
ops.push(Op::SetTextRenderingMode {
|
||
mode: TextRenderingMode::Invisible,
|
||
});
|
||
ops.push(Op::SetTextMatrix {
|
||
matrix: printpdf::TextMatrix::Raw([
|
||
cos_r * width_k,
|
||
sin_r * width_k,
|
||
-sin_r,
|
||
cos_r,
|
||
x_pt,
|
||
y_pt,
|
||
]),
|
||
});
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
||
});
|
||
ops.push(Op::ShowText {
|
||
items: vec![TextItem::Text(run.text.clone())],
|
||
});
|
||
ops.push(Op::EndTextSection);
|
||
emitted += 1;
|
||
}
|
||
}
|
||
emitted
|
||
}
|
||
|
||
/// Whether every char in `text` survives Base-14 WinAnsi encoding.
|
||
///
|
||
/// ASCII + Latin-1 incl. French accents and the `°±Ø` DXF specials — but NOT
|
||
/// the C1 control range U+0080–U+009F (unassigned in WinAnsi; emitting them
|
||
/// would mojibake). Anything else is rejected per-run so the exporter never
|
||
/// emits a wrong `Tj` — those runs keep their outlines and stay unsearchable.
|
||
/// (Runs set in an embedded subset bypass this gate: Identity-H covers any
|
||
/// glyph the subset carries.)
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn is_winansi_encodable(text: &str) -> bool {
|
||
text.chars().all(|c| {
|
||
let cp = c as u32;
|
||
cp <= 0xFF && !(0x80..=0x9F).contains(&cp)
|
||
})
|
||
}
|
||
|
||
// ── Embedded subset fonts (one system family × weight, cut to used glyphs) ──
|
||
|
||
/// One embedded subset TrueType font for the search layer: a single system
|
||
/// family at one weight, cut by `allsorts` to the glyphs the plot uses.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
struct EmbeddedSubset {
|
||
family: String,
|
||
bold: bool,
|
||
font_id: printpdf::FontId,
|
||
/// Subset-space GID per char, re-parsed from the subset bytes (never
|
||
/// assumed from allsorts' assignment order).
|
||
gid_of: std::collections::BTreeMap<char, u16>,
|
||
/// Raw advance per subset GID, in font units (hmtx, for Tz).
|
||
width_of: std::collections::BTreeMap<u16, u16>,
|
||
upem: u16,
|
||
/// Cap-height fraction of em (OS/2 sCapHeight, else 0.7).
|
||
cap_ratio: f32,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl EmbeddedSubset {
|
||
/// Width-match factor for a run: laid advance ÷ natural advance at the
|
||
/// run's cap height. 1.0 when the widths are unknown (never explodes a
|
||
/// selection highlight: clamped to a sane band).
|
||
fn width_factor(&self, run: &SearchableTextRun) -> f32 {
|
||
let natural: u32 = run
|
||
.text
|
||
.chars()
|
||
.filter_map(|c| self.gid_of.get(&c))
|
||
.filter_map(|g| self.width_of.get(g))
|
||
.map(|w| *w as u32)
|
||
.sum();
|
||
if natural == 0 || run.adv_width <= 0.0 || run.height <= 0.0 {
|
||
return 1.0;
|
||
}
|
||
// laid (drawing mm) vs natural at cap height, both in em fractions:
|
||
// k = adv / (height * Σw / (upem * cap)).
|
||
let k = run.adv_width * self.upem as f32 * self.cap_ratio
|
||
/ (run.height * natural as f32);
|
||
if !k.is_finite() {
|
||
1.0
|
||
} else {
|
||
k.clamp(0.25, 4.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Embedded subsets for one export (Helvetica fallback needs no entry).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[derive(Default)]
|
||
struct SearchableFontSet {
|
||
subsets: Vec<EmbeddedSubset>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl SearchableFontSet {
|
||
/// Collect every TTF-resolvable run's chars per (family, bold), subset
|
||
/// each bucket with `allsorts`, and register the subsets on the document.
|
||
/// Anything unresolvable (SHX/LFF names, big-font pairs, CFF outlines,
|
||
/// missing files) simply gets no entry — those runs take Helvetica.
|
||
fn build(doc: &mut PdfDocument, pages: &[PdfPageInput]) -> Self {
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
let mut buckets: BTreeMap<(String, bool), BTreeSet<char>> = BTreeMap::new();
|
||
for page in pages {
|
||
for wire in page.content.wires.iter() {
|
||
for run in &wire.searchable_text {
|
||
if run.text.is_empty() {
|
||
continue;
|
||
}
|
||
if let crate::scene::text::font_face::Face::Ttf { family, .. } =
|
||
crate::scene::text::font_face::Face::resolve(&run.font)
|
||
{
|
||
buckets
|
||
.entry((family, run.bold))
|
||
.or_default()
|
||
.extend(run.text.chars());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut out = Self::default();
|
||
for ((family, bold), chars) in &buckets {
|
||
if let Some(subset) = subset_family(doc, family, *bold, chars) {
|
||
out.subsets.push(subset);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn find(&self, family: &str, bold: bool) -> Option<&EmbeddedSubset> {
|
||
self.subsets
|
||
.iter()
|
||
.find(|s| s.family == family && s.bold == bold)
|
||
}
|
||
}
|
||
|
||
/// Subset one system family to `chars`, embed the subset, and register it.
|
||
/// Returns `None` whenever embedding is impossible or pointless (no file, CFF
|
||
/// outlines the stub can't describe, subset failure, nothing mappable) — the
|
||
/// caller falls back to Helvetica, never to an invalid font dict.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn subset_family(
|
||
doc: &mut PdfDocument,
|
||
family: &str,
|
||
bold: bool,
|
||
chars: &std::collections::BTreeSet<char>,
|
||
) -> Option<EmbeddedSubset> {
|
||
use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider};
|
||
let weight = if bold {
|
||
fontdb::Weight::BOLD
|
||
} else {
|
||
fontdb::Weight::NORMAL
|
||
};
|
||
let (bytes, index) = crate::scene::text::sysfont::with_face_data_weighted(
|
||
family,
|
||
weight,
|
||
|data, index| (data.to_vec(), index),
|
||
)?;
|
||
// Original GIDs for the used chars (+ .notdef, mandatory for subsetting).
|
||
let orig = ttf_parser::Face::parse(&bytes, index).ok()?;
|
||
let mut ids: Vec<u16> = vec![0];
|
||
for c in chars {
|
||
if let Some(gid) = orig.glyph_index(*c) {
|
||
if !ids.contains(&gid.0) {
|
||
ids.push(gid.0);
|
||
}
|
||
}
|
||
}
|
||
if ids.len() <= 1 {
|
||
return None;
|
||
}
|
||
let scope = ReadScope::new(&bytes);
|
||
let font_file = scope.read::<FontData<'_>>().ok()?;
|
||
let provider = font_file.table_provider(index as usize).ok()?;
|
||
// The no-text_layout ParsedFont stub can only describe TrueType
|
||
// (CIDFontType2 + FontFile2) — never mislabel CFF outlines as TrueType.
|
||
if provider.has_table(allsorts::tag::CFF) || provider.has_table(allsorts::tag::CFF2) {
|
||
return None;
|
||
}
|
||
let subset_bytes = allsorts::subset::subset(
|
||
&provider,
|
||
&ids,
|
||
&allsorts::subset::SubsetProfile::Pdf,
|
||
allsorts::subset::CmapTarget::Unicode,
|
||
)
|
||
.ok()?;
|
||
// Re-parse the SUBSET: GID assignment is allsorts' business, and the maps
|
||
// below must be in subset space. Anything unmappable stays out.
|
||
let sub = ttf_parser::Face::parse(&subset_bytes, 0).ok()?;
|
||
let upem = sub.units_per_em().max(1);
|
||
let cap_ratio = sub
|
||
.capital_height()
|
||
.filter(|c| *c > 0)
|
||
.map(|c| c as f32 / upem as f32)
|
||
.filter(|r| (0.2..=1.0).contains(r))
|
||
.unwrap_or(0.7);
|
||
let mut gid_chars: std::collections::BTreeMap<char, u16> = std::collections::BTreeMap::new();
|
||
let mut width_of: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
|
||
for c in chars {
|
||
if let Some(gid) = sub.glyph_index(*c) {
|
||
gid_chars.insert(*c, gid.0);
|
||
width_of.insert(gid.0, sub.glyph_hor_advance(gid).unwrap_or(0));
|
||
}
|
||
}
|
||
if gid_chars.is_empty() {
|
||
return None;
|
||
}
|
||
// printpdf (no text_layout) embeds `original_bytes` verbatim as the
|
||
// CIDFontType2 stream and derives ToUnicode/widths from these maps — so
|
||
// handing it the SUBSET bytes + subset-space maps yields a true subset
|
||
// embed with no lopdf surgery. (Metrics out before the bytes move.)
|
||
let (ascent, descent) = (sub.ascender(), sub.descender());
|
||
let cp_map: std::collections::BTreeMap<u32, u16> =
|
||
gid_chars.iter().map(|(c, g)| (*c as u32, *g)).collect();
|
||
let parsed = printpdf::ParsedFont::with_glyph_data(
|
||
subset_bytes,
|
||
0,
|
||
Some(family.to_string()),
|
||
cp_map,
|
||
width_of.clone(),
|
||
upem,
|
||
printpdf::FontMetrics { ascent, descent },
|
||
);
|
||
let font_id = doc.add_font(&parsed);
|
||
Some(EmbeddedSubset {
|
||
family: family.to_string(),
|
||
bold,
|
||
font_id,
|
||
gid_of: gid_chars,
|
||
width_of,
|
||
upem,
|
||
cap_ratio,
|
||
})
|
||
}
|
||
|
||
/// Re-emit every wire's SDF text as vector geometry.
|
||
///
|
||
/// Each visible glyph rides on `wire.text_verts` as one 6-vertex quad (two
|
||
/// triangles) whose corners are the glyph's atlas `plane` rect run through the
|
||
/// text transform. We recover the glyph's outline / fill from the atlas by the
|
||
/// quad's `uv_min` and map it into that quad by affine interpolation of the
|
||
/// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType
|
||
/// glyph emits filled triangles, exactly where the SDF quad sits.
|
||
///
|
||
/// Always drawn: the visible glyph rendering (strokes for LFF/SHX pen fonts,
|
||
/// fills for TrueType outlines, solid bars for decorations). The invisible
|
||
/// searchable layer above adds extractability; it never replaces this.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn emit_text(
|
||
ops: &mut Vec<Op>,
|
||
fonts: &mut TextFonts,
|
||
wires: &[WireModel],
|
||
ox: f64,
|
||
oy: f64,
|
||
scale: f32,
|
||
plot_style: Option<&PlotStyleTable>,
|
||
options: PdfPlotOptions,
|
||
) -> bool {
|
||
use crate::scene::text::sdf_atlas;
|
||
|
||
if wires.iter().all(|w| w.text_verts.is_empty()) {
|
||
return false;
|
||
}
|
||
// The atlas' baked-glyph geometry, snapshotted once per export.
|
||
let Some(snapshot) = fonts.glyph_table() else {
|
||
return false;
|
||
};
|
||
let (table, solid_key) = (&snapshot.0, snapshot.1);
|
||
|
||
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
|
||
// above only re-emits it on change, so whatever the last wire needed is
|
||
// still active here — without this reset a drawing whose last wire carries a
|
||
// HIDDEN/CENTER linetype prints its glyph outlines dashed.
|
||
ops.push(Op::SetLineDashPattern {
|
||
dash: LineDashPattern::default(),
|
||
});
|
||
|
||
let mut embedded = true;
|
||
for wire in wires {
|
||
let verts = &wire.text_verts;
|
||
if verts.is_empty() {
|
||
embedded = false;
|
||
continue;
|
||
}
|
||
// A run drawn wholly in faces the PDF can embed goes out as real text
|
||
// and is its own searchable layer. Any other run keeps its outlines and
|
||
// the invisible layer carries its text: one copy of the text either way.
|
||
let embed = fonts.embeds_all(verts, table, solid_key);
|
||
embedded &= embed;
|
||
// Mirror the wire pass: indexed style color, screening, and pen width.
|
||
let mut ctb_color: Option<[f32; 3]> = None;
|
||
let mut lw_override: Option<f32> = None;
|
||
let mut screening = 1.0;
|
||
if let Some(ctb) = plot_style {
|
||
if wire.aci > 0 {
|
||
ctb_color = ctb.resolve_color(wire.aci);
|
||
lw_override = options.object_lineweights.then(|| {
|
||
ctb.resolve_lineweight(wire.aci).map(|mm| {
|
||
let divisor = if options.scale_lineweights {
|
||
1.0
|
||
} else {
|
||
scale.max(1e-6)
|
||
};
|
||
(mm * MM_TO_PT).max(0.1) / divisor
|
||
})
|
||
}).flatten();
|
||
screening = ctb.resolve_screening(wire.aci);
|
||
}
|
||
}
|
||
let mut gi = 0;
|
||
while gi + 6 <= verts.len() {
|
||
let quad = &verts[gi..gi + 6];
|
||
gi += 6;
|
||
|
||
let a = quad[0].color[3];
|
||
if a < 0.01 {
|
||
continue;
|
||
}
|
||
// A CTB colour override wins over the white-sheet adaptation, exactly
|
||
// as in the wire pass — else a monochrome.ctb plot plots the lines
|
||
// black and leaves the text on its screen colour.
|
||
let rgb = ctb_color.unwrap_or_else(|| {
|
||
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]])
|
||
});
|
||
let [r, g, b] = plotted_color(rgb, a, screening, options);
|
||
|
||
// Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl].
|
||
let bl = glyph_world_xy(&quad[0]);
|
||
let br = glyph_world_xy(&quad[1]);
|
||
let tr = glyph_world_xy(&quad[2]);
|
||
let tl = glyph_world_xy(&quad[5]);
|
||
// `tl` carries uv = (uv_min.x, uv_min.y) — the atlas tile key.
|
||
let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]);
|
||
|
||
// Cancel the offset in f64, then narrow: the sheet-mm result is a
|
||
// small number even when the world coordinate is UTM-scale.
|
||
let point = |wx: f64, wy: f64| Point::new(Mm((wx + ox) as f32), Mm((wy + oy) as f32));
|
||
|
||
if let Some(ge) = table.get(&key) {
|
||
// Affine basis of the quad: plane_min → bl, +x → br, +y → tl.
|
||
// The glyph-space maths is small and stays f32; only the lift into
|
||
// world coordinates needs f64.
|
||
let (pmin, pmax) = (ge.plane_min, ge.plane_max);
|
||
let (sx, sy) = (pmax[0] - pmin[0], pmax[1] - pmin[1]);
|
||
if sx.abs() < 1e-9 || sy.abs() < 1e-9 {
|
||
continue;
|
||
}
|
||
let map = |p: [f32; 2]| -> Point {
|
||
let u = ((p[0] - pmin[0]) / sx) as f64;
|
||
let v = ((p[1] - pmin[1]) / sy) as f64;
|
||
let wx = bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]);
|
||
let wy = bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]);
|
||
point(wx, wy)
|
||
};
|
||
|
||
if !ge.fill_tris.is_empty() {
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
||
});
|
||
// Filled TrueType glyph from a face we can embed: draw it as
|
||
// text in that font. Its outline tessellates into hundreds
|
||
// of triangles for a CJK face, which made text-heavy sheets
|
||
// export at hundreds of MB and crawl in viewers.
|
||
if let Some(source) = ge.source.as_ref().filter(|_| embed) {
|
||
if let Some(font) = fonts.font_for(source, ge.ch) {
|
||
let matrix =
|
||
glyph_text_matrix(source, pmin, [sx, sy], bl, br, tl, ox, oy);
|
||
push_glyph_text(ops, font, source.gid, ge.ch, matrix);
|
||
continue;
|
||
}
|
||
}
|
||
// Otherwise one filled triangle per triple.
|
||
for tri in ge.fill_tris.chunks_exact(3) {
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: vec![PolygonRing {
|
||
points: tri
|
||
.iter()
|
||
.map(|&p| LinePoint { p: map(p), bezier: false })
|
||
.collect(),
|
||
}],
|
||
mode: PaintMode::Fill,
|
||
winding_order: WindingOrder::NonZero,
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
// Stroke (LFF/SHX pen) font or hollow glyph: polylines.
|
||
// Match the SDF atlas' nominal glyph-space pen instead of
|
||
// borrowing the entity lineweight: Roman Duplex and similar
|
||
// multi-stroke faces rely on that band to close the narrow
|
||
// gaps between parallel centrelines. An explicit CTB
|
||
// lineweight still wins and stays absolute under the plot CTM.
|
||
ops.push(Op::SetOutlineColor {
|
||
col: Color::Rgb(Rgb {
|
||
r,
|
||
g,
|
||
b,
|
||
icc_profile: None,
|
||
}),
|
||
});
|
||
let pen = if let Some(ctb_pen) = lw_override {
|
||
if ge.bold {
|
||
ctb_pen * 1.7
|
||
} else {
|
||
ctb_pen
|
||
}
|
||
} else {
|
||
let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2))
|
||
.sqrt()
|
||
/ sy.abs() as f64) as f32;
|
||
(2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT)
|
||
.max(0.1)
|
||
};
|
||
ops.push(Op::SetOutlineThickness { pt: Pt(pen) });
|
||
for stroke in &ge.strokes {
|
||
if stroke.len() < 2 {
|
||
continue;
|
||
}
|
||
ops.push(Op::DrawLine {
|
||
line: Line {
|
||
points: stroke
|
||
.iter()
|
||
.map(|&p| LinePoint { p: map(p), bezier: false })
|
||
.collect(),
|
||
is_closed: false,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
} else if key == solid_key {
|
||
// Decoration bar (underline / overline / strike): the quad is a
|
||
// solid-texel rectangle — fill it directly from its corners.
|
||
ops.push(Op::SetFillColor {
|
||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
||
});
|
||
ops.push(Op::DrawPolygon {
|
||
polygon: Polygon {
|
||
rings: vec![PolygonRing {
|
||
points: [bl, br, tr, tl]
|
||
.iter()
|
||
.map(|&c| LinePoint { p: point(c[0], c[1]), bezier: false })
|
||
.collect(),
|
||
}],
|
||
mode: PaintMode::Fill,
|
||
winding_order: WindingOrder::NonZero,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
embedded
|
||
}
|
||
|
||
// ── Embedded TrueType text ─────────────────────────────────────────────────
|
||
|
||
/// The atlas export table plus its solid-tile key.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
type GlyphTable = std::sync::Arc<(
|
||
std::collections::HashMap<u64, crate::scene::text::sdf_atlas::GlyphExport>,
|
||
u64,
|
||
)>;
|
||
|
||
/// The TrueType faces a PDF export draws text with, and the glyphs each uses.
|
||
///
|
||
/// Pages register glyphs as they are drawn (each under a font id fixed up
|
||
/// front); once every page is done, [`TextFonts::install`] subsets each face to
|
||
/// the glyphs actually used, renumbers them in the page streams, and adds the
|
||
/// subset fonts to the document. printpdf 0.9 would otherwise embed whole
|
||
/// fonts (its own subsetting is disabled), and a CJK face is megabytes.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[derive(Default)]
|
||
struct TextFonts {
|
||
/// The atlas export table, snapshotted once: every page's text quads are
|
||
/// laid out before the export starts, so one snapshot serves the whole
|
||
/// document. Re-walking the atlas per text wire made a 28-sheet set with
|
||
/// ~4 000 text entities take over nine minutes.
|
||
glyphs: Option<GlyphTable>,
|
||
fonts: Vec<EmbeddedFont>,
|
||
/// Font file identity → index into `fonts`; `None` = printpdf can't parse
|
||
/// the face, so its glyphs keep the outline fill.
|
||
by_blob: std::collections::HashMap<(usize, u32, u32), Option<usize>>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
struct EmbeddedFont {
|
||
blob: std::sync::Arc<crate::scene::text::ttf_glyph::FontBlob>,
|
||
id: FontId,
|
||
/// Original glyph id → the character it draws (for ToUnicode).
|
||
used: std::collections::BTreeMap<u16, char>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl TextFonts {
|
||
/// The atlas' glyph export table (and solid-tile key), built on first use.
|
||
fn glyph_table(&mut self) -> Option<GlyphTable> {
|
||
use crate::scene::text::sdf_atlas;
|
||
if self.glyphs.is_none() {
|
||
let atlas = sdf_atlas::text_atlas().lock().ok()?;
|
||
self.glyphs = Some(std::sync::Arc::new((
|
||
atlas.export_table(),
|
||
sdf_atlas::uv_key(atlas.solid_uv()),
|
||
)));
|
||
}
|
||
self.glyphs.clone()
|
||
}
|
||
|
||
/// The font id to draw `source`'s glyph with, recording the glyph as used.
|
||
/// `None` when the face can't be embedded.
|
||
fn font_for(
|
||
&mut self,
|
||
source: &crate::scene::text::ttf_glyph::GlyphSource,
|
||
ch: char,
|
||
) -> Option<FontId> {
|
||
let slot = self.slot(source)?;
|
||
let font = &mut self.fonts[slot];
|
||
font.used.entry(source.gid).or_insert(ch);
|
||
Some(font.id.clone())
|
||
}
|
||
|
||
/// The `fonts` slot of `source`'s face; `None` when printpdf can't parse it.
|
||
fn slot(&mut self, source: &crate::scene::text::ttf_glyph::GlyphSource) -> Option<usize> {
|
||
match self.by_blob.get(&source.font.id) {
|
||
Some(slot) => *slot,
|
||
None => {
|
||
let parses =
|
||
ParsedFont::from_bytes(&source.font.data, source.font.index, &mut Vec::new())
|
||
.is_some();
|
||
let slot = parses.then(|| {
|
||
self.fonts.push(EmbeddedFont {
|
||
blob: source.font.clone(),
|
||
id: FontId::new(),
|
||
used: std::collections::BTreeMap::new(),
|
||
});
|
||
self.fonts.len() - 1
|
||
});
|
||
self.by_blob.insert(source.font.id, slot);
|
||
slot
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether every glyph of `verts` is a filled glyph of a face the PDF can
|
||
/// embed, so the whole run can go out as real text.
|
||
fn embeds_all(
|
||
&mut self,
|
||
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
|
||
table: &std::collections::HashMap<u64, crate::scene::text::sdf_atlas::GlyphExport>,
|
||
solid_key: u64,
|
||
) -> bool {
|
||
use crate::scene::text::sdf_atlas;
|
||
let mut any = false;
|
||
for quad in verts.chunks_exact(6) {
|
||
if quad[0].color[3] < 0.01 {
|
||
continue;
|
||
}
|
||
let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]);
|
||
if key == solid_key {
|
||
continue;
|
||
}
|
||
let Some(ge) = table.get(&key) else {
|
||
continue;
|
||
};
|
||
let Some(source) = ge.source.as_ref().filter(|_| !ge.fill_tris.is_empty()) else {
|
||
return false;
|
||
};
|
||
if self.slot(source).is_none() {
|
||
return false;
|
||
}
|
||
any = true;
|
||
}
|
||
any
|
||
}
|
||
|
||
/// Subset each face to its used glyphs, renumber those glyphs in the page
|
||
/// streams, and register the fonts under the ids the pages already use.
|
||
/// A face that fails to subset is embedded whole, ids unchanged.
|
||
fn install(self, doc: &mut PdfDocument) {
|
||
// A face only probed by `embeds_all` for a run that kept its outlines
|
||
// draws nothing.
|
||
for font in self.fonts.into_iter().filter(|font| !font.used.is_empty()) {
|
||
// `.notdef` must stay glyph 0; the subset numbers glyphs in list order.
|
||
let gids: Vec<u16> = std::iter::once(0)
|
||
.chain(font.used.keys().copied().filter(|&gid| gid != 0))
|
||
.collect();
|
||
let subset = subset_font(&font.blob, &gids)
|
||
.and_then(|bytes| ParsedFont::from_bytes(&bytes, 0, &mut Vec::new()));
|
||
let parsed = match subset {
|
||
Some(parsed) => {
|
||
let remap: std::collections::HashMap<u16, u16> = gids
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(new, &old)| (old, new as u16))
|
||
.collect();
|
||
renumber_glyphs(&mut doc.pages, &font.id, &remap);
|
||
parsed
|
||
}
|
||
None => {
|
||
log::warn!("PDF export: font subsetting failed; embedding the whole face");
|
||
let whole =
|
||
ParsedFont::from_bytes(&font.blob.data, font.blob.index, &mut Vec::new());
|
||
match whole {
|
||
Some(parsed) => parsed,
|
||
None => continue, // `font_for` already checked it parses
|
||
}
|
||
}
|
||
};
|
||
doc.resources
|
||
.fonts
|
||
.map
|
||
.insert(font.id, PdfFont::new(parsed));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `blob`'s face reduced to `gids` (glyph 0 first), keeping the hinting tables
|
||
/// a tricky CJK face (DFKai-SB, MingLiU) needs to draw its strokes in place.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn subset_font(blob: &crate::scene::text::ttf_glyph::FontBlob, gids: &[u16]) -> Option<Vec<u8>> {
|
||
use allsorts::binary::read::ReadScope;
|
||
use allsorts::font_data::FontData;
|
||
use allsorts::subset::{subset, CmapTarget, SubsetProfile};
|
||
|
||
let font = ReadScope::new(&blob.data).read::<FontData<'_>>().ok()?;
|
||
let provider = font.table_provider(blob.index as usize).ok()?;
|
||
subset(
|
||
&provider,
|
||
gids,
|
||
&SubsetProfile::Pdf,
|
||
CmapTarget::Unrestricted,
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
/// Rewrite the glyph ids shown in font `id` through `remap` (original → subset).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn renumber_glyphs(
|
||
pages: &mut [PdfPage],
|
||
id: &FontId,
|
||
remap: &std::collections::HashMap<u16, u16>,
|
||
) {
|
||
for page in pages {
|
||
let mut in_font = false;
|
||
for op in &mut page.ops {
|
||
match op {
|
||
Op::SetFont { font, .. } => {
|
||
in_font = matches!(font, PdfFontHandle::External(fid) if fid == id);
|
||
}
|
||
Op::ShowText { items } if in_font => {
|
||
for item in items {
|
||
if let TextItem::GlyphIds(codepoints) = item {
|
||
for codepoint in codepoints {
|
||
if let Some(&gid) = remap.get(&codepoint.gid) {
|
||
codepoint.gid = gid;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Text matrix that lays the embedded glyph exactly over its SDF quad.
|
||
///
|
||
/// The quad maps the glyph's 9-unit tile rect `pmin .. pmin + size` onto the
|
||
/// world corners `bl`/`br`/`tl`; the outline was normalised font-unit × `k`,
|
||
/// so one em (`units_per_em` font units) spans `k · units_per_em` 9-units.
|
||
/// With `Tf` size 1 a text-space unit is one em, which this matrix carries to
|
||
/// sheet points under the page's CTM — the same space the wire pass draws in.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn glyph_text_matrix(
|
||
source: &crate::scene::text::ttf_glyph::GlyphSource,
|
||
pmin: [f32; 2],
|
||
size: [f32; 2],
|
||
bl: [f64; 2],
|
||
br: [f64; 2],
|
||
tl: [f64; 2],
|
||
ox: f64,
|
||
oy: f64,
|
||
) -> [f32; 6] {
|
||
let em = source.k as f64 * source.units_per_em.max(1) as f64;
|
||
let (sx, sy) = (size[0] as f64, size[1] as f64);
|
||
// World displacement per 9-unit along the glyph's x and y axes.
|
||
let ex = [(br[0] - bl[0]) / sx, (br[1] - bl[1]) / sx];
|
||
let ey = [(tl[0] - bl[0]) / sy, (tl[1] - bl[1]) / sy];
|
||
// Glyph origin (9-unit 0,0 = pen position on the baseline) in world space.
|
||
let (px, py) = (pmin[0] as f64, pmin[1] as f64);
|
||
let origin = [
|
||
bl[0] - px * ex[0] - py * ey[0],
|
||
bl[1] - px * ex[1] - py * ey[1],
|
||
];
|
||
let pt = MM_TO_PT as f64;
|
||
[
|
||
(ex[0] * em * pt) as f32,
|
||
(ex[1] * em * pt) as f32,
|
||
(ey[0] * em * pt) as f32,
|
||
(ey[1] * em * pt) as f32,
|
||
((origin[0] + ox) * pt) as f32,
|
||
((origin[1] + oy) * pt) as f32,
|
||
]
|
||
}
|
||
|
||
/// One glyph as a text object: font `font` at size 1, positioned by `matrix`.
|
||
/// The character rides along as the glyph's Unicode so the text is searchable.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn push_glyph_text(ops: &mut Vec<Op>, font: FontId, gid: u16, ch: char, matrix: [f32; 6]) {
|
||
ops.push(Op::StartTextSection);
|
||
ops.push(Op::SetFont {
|
||
font: PdfFontHandle::External(font),
|
||
size: Pt(1.0),
|
||
});
|
||
ops.push(Op::SetTextMatrix {
|
||
matrix: TextMatrix::Raw(matrix),
|
||
});
|
||
ops.push(Op::ShowText {
|
||
items: vec![TextItem::GlyphIds(vec![Codepoint {
|
||
gid,
|
||
offset: 0.0,
|
||
cid: Some(ch.to_string()),
|
||
}])],
|
||
});
|
||
ops.push(Op::EndTextSection);
|
||
}
|
||
|
||
/// Everything searchable in an exported file: the raw bytes (printpdf
|
||
/// leaves small content streams uncompressed) plus any zlib streams that
|
||
/// decode to mostly-printable text, so the assertions hold either way.
|
||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||
pub(crate) fn pdf_stream_text(bytes: &[u8]) -> String {
|
||
use std::io::Read as _;
|
||
let mut text = String::from_utf8_lossy(bytes).into_owned();
|
||
for start in 0..bytes.len().saturating_sub(2) {
|
||
// zlib streams start with a 2-byte header: deflate method, valid check.
|
||
let (cmf, flg) = (bytes[start], bytes[start + 1]);
|
||
if cmf & 0x0f != 8 || ((cmf as u16) << 8 | flg as u16) % 31 != 0 {
|
||
continue;
|
||
}
|
||
let mut decoded = Vec::new();
|
||
if flate2::read::ZlibDecoder::new(&bytes[start..])
|
||
.read_to_end(&mut decoded)
|
||
.is_ok()
|
||
&& decoded.len() > 32
|
||
{
|
||
let printable = decoded
|
||
.iter()
|
||
.filter(|&&b| matches!(b, b'\n' | b'\r' | b'\t' | 32..=126))
|
||
.count();
|
||
if printable * 10 >= decoded.len() * 9 {
|
||
text.push_str(&String::from_utf8_lossy(&decoded));
|
||
}
|
||
}
|
||
}
|
||
text
|
||
}
|
||
|
||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn test_page(wires: Vec<PlotWire>) -> PdfPageInput {
|
||
PdfPageInput {
|
||
content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() },
|
||
paper_w: 210.0,
|
||
paper_h: 297.0,
|
||
offset_x: 0.0,
|
||
offset_y: 0.0,
|
||
rotation_deg: 0,
|
||
scale: 1.0,
|
||
clip: None,
|
||
options: PdfPlotOptions::default(),
|
||
plot_style: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn atomic_write_replaces_an_existing_pdf() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"ocs-pdf-replace-{}-{}.pdf",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
std::fs::write(&path, b"old").unwrap();
|
||
|
||
write_pdf_atomically(&path, b"new").unwrap();
|
||
|
||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||
std::fs::remove_file(path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn clip_and_scale_emit_pdf_bytes() {
|
||
let w = PlotWire {
|
||
wire: WireModel::solid(
|
||
"test".into(),
|
||
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
|
||
WireModel::WHITE,
|
||
false,
|
||
),
|
||
draw_depth: 0.0,
|
||
};
|
||
let mut page = test_page(vec![w]);
|
||
page.scale = 2.0;
|
||
page.clip = Some((10.0, 10.0, 100.0, 100.0));
|
||
let bytes = build_pdf_pages(&[page], None).unwrap();
|
||
// A valid PDF is produced (starts with the PDF header) and is non-trivial.
|
||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
|
||
}
|
||
|
||
|
||
#[test]
|
||
fn export_fallback_plot_style_recolors_wires() {
|
||
// ACI 1 carrying pure device red: without a style it must export as an
|
||
// RGB operator; the monochrome fallback must recolor it. Guards the
|
||
// automation plot path, where the per-page style can be dropped.
|
||
let make_page = || {
|
||
let mut wire = PlotWire {
|
||
wire: WireModel::solid(
|
||
"test".into(),
|
||
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
|
||
[1.0, 0.0, 0.0, 1.0],
|
||
false,
|
||
),
|
||
draw_depth: 0.0,
|
||
};
|
||
wire.wire.aci = 1;
|
||
test_page(vec![wire])
|
||
};
|
||
|
||
let unstyled = pdf_stream_text(&build_pdf_pages(&[make_page()], None).unwrap());
|
||
let monochrome =
|
||
PlotStyleTable::builtin("monochrome.ctb").expect("shipped monochrome.ctb parses");
|
||
let styled =
|
||
pdf_stream_text(&build_pdf_pages(&[make_page()], Some(&monochrome)).unwrap());
|
||
|
||
assert!(
|
||
unstyled.contains("1 0 0 rg") || unstyled.contains("1 0 0 RG"),
|
||
"unstyled export should keep the wire's RGB color"
|
||
);
|
||
assert!(
|
||
!styled.contains("1 0 0 rg") && !styled.contains("1 0 0 RG"),
|
||
"monochrome fallback must recolor the wire"
|
||
);
|
||
}
|
||
|
||
/// Parse every stroke-width operator (`<pt> w`) out of the content text.
|
||
fn stroke_widths(text: &str) -> Vec<f32> {
|
||
let tokens: Vec<&str> = text.split_whitespace().collect();
|
||
tokens
|
||
.windows(2)
|
||
.filter_map(|pair| {
|
||
if pair[1] == "w" {
|
||
pair[0].parse::<f32>().ok()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn distinct_widths(mut widths: Vec<f32>) -> Vec<f32> {
|
||
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
widths.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||
widths
|
||
}
|
||
|
||
#[test]
|
||
fn plot_honors_per_object_lineweight_with_and_without_style() {
|
||
// Two objects whose resolved lineweights differ (as two layers set to
|
||
// 0.13 mm and 0.50 mm produce): the PDF must carry two distinct pen
|
||
// widths, and a plot style that leaves weights at "use object" must
|
||
// keep that hierarchy.
|
||
let make_page = |px: f32| {
|
||
let mut wire = PlotWire {
|
||
wire: WireModel::solid(
|
||
"test".into(),
|
||
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
|
||
[1.0, 0.0, 0.0, 1.0],
|
||
false,
|
||
),
|
||
draw_depth: 0.0,
|
||
};
|
||
wire.wire.aci = 7;
|
||
wire.wire.line_weight_px = px;
|
||
test_page(vec![wire])
|
||
};
|
||
|
||
let monochrome =
|
||
PlotStyleTable::builtin("monochrome.ctb").expect("shipped monochrome.ctb parses");
|
||
eprintln!(
|
||
"monochrome resolve_lineweight(7) = {:?}",
|
||
monochrome.resolve_lineweight(7)
|
||
);
|
||
|
||
let unstyled = stroke_widths(&pdf_stream_text(
|
||
&build_pdf_pages(&[make_page(1.0), make_page(3.0)], None).unwrap(),
|
||
));
|
||
let unstyled = distinct_widths(unstyled);
|
||
assert_eq!(unstyled.len(), 2, "two object weights, two pens: {unstyled:?}");
|
||
assert!(
|
||
(unstyled[1] / unstyled[0] - 3.0).abs() < 0.1,
|
||
"pen widths follow the object weights 1px:3px: {unstyled:?}"
|
||
);
|
||
|
||
let styled = stroke_widths(&pdf_stream_text(
|
||
&build_pdf_pages(&[make_page(1.0), make_page(3.0)], Some(&monochrome)).unwrap(),
|
||
));
|
||
let styled = distinct_widths(styled);
|
||
assert_eq!(
|
||
styled.len(),
|
||
2,
|
||
"monochrome.ctb leaves weights at 'use object', so the two weights survive: {styled:?}"
|
||
);
|
||
assert!(
|
||
(styled[1] / styled[0] - 3.0).abs() < 0.1,
|
||
"weight hierarchy matches the unstyled plot: {styled:?}"
|
||
);
|
||
}
|
||
|
||
// Build a WireModel carrying the SDF glyph quads for `text` in the embedded
|
||
// "txt" stroke font, laid out into the process-wide atlas emit_text reads.
|
||
fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire {
|
||
use crate::scene::pipeline::text_gpu::push_glyph_vertices;
|
||
use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas};
|
||
let (quads, _) = {
|
||
let mut atlas = sdf_atlas::text_atlas().lock().unwrap();
|
||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, text)
|
||
};
|
||
assert!(!quads.is_empty(), "stroke glyphs laid out for {text:?}");
|
||
let mut verts = Vec::new();
|
||
push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0);
|
||
PlotWire {
|
||
wire: WireModel {
|
||
text_verts: verts,
|
||
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
|
||
},
|
||
draw_depth: 0.0,
|
||
}
|
||
}
|
||
|
||
// End-to-end: a page whose only content is SDF text produces a larger PDF
|
||
// than the same page with the text stripped — proving text reaches the file.
|
||
#[test]
|
||
fn text_grows_the_pdf_vs_no_text() {
|
||
let wire = text_wire("HELLO", [20.0, 20.0, 0.0]);
|
||
let mut blank = wire.clone();
|
||
blank.wire.text_verts.clear();
|
||
|
||
let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||
let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap();
|
||
assert!(with_text.starts_with(b"%PDF"));
|
||
assert!(
|
||
with_text.len() > no_text.len(),
|
||
"text did not add content: {} !> {}",
|
||
with_text.len(),
|
||
no_text.len()
|
||
);
|
||
}
|
||
|
||
fn searchable_wire(text: &str, origin: [f64; 3]) -> PlotWire {
|
||
use crate::scene::model::wire_model::SearchableTextRun;
|
||
PlotWire {
|
||
wire: WireModel {
|
||
searchable_text: vec![SearchableTextRun {
|
||
text: text.to_string(),
|
||
origin,
|
||
height: 10.0,
|
||
rotation: 0.0,
|
||
color: [0.0, 0.0, 0.0, 1.0],
|
||
bold: false,
|
||
font: "txt".into(),
|
||
adv_width: 10.0 * text.chars().count().max(1) as f32,
|
||
}],
|
||
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
|
||
},
|
||
draw_depth: 0.0,
|
||
}
|
||
}
|
||
|
||
// Outline-only export exposes zero BT operators and no extractable text;
|
||
// a preserved run must emit a real invisible (`Tr 3`) `BT … Tj ET` with
|
||
// the string, on top of the unchanged outlines.
|
||
#[test]
|
||
fn searchable_text_emits_bt_tj_with_string() {
|
||
let wire = searchable_wire("CMa-03", [20.0, 30.0, 0.0]);
|
||
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||
let stream = pdf_stream_text(&bytes);
|
||
assert!(stream.contains("BT"), "no text object (BT) in searchable export");
|
||
assert!(
|
||
stream.contains("CMa-03"),
|
||
"searchable string missing from content stream"
|
||
);
|
||
assert!(
|
||
stream.contains("/Helvetica"),
|
||
"searchable export should reference the text font resource"
|
||
);
|
||
assert!(
|
||
stream.contains("Tj") || stream.contains("TJ"),
|
||
"no text-showing operator (Tj/TJ)"
|
||
);
|
||
assert!(
|
||
stream.contains("Tr"),
|
||
"searchable layer must be invisible (Tr rendering mode)"
|
||
);
|
||
}
|
||
|
||
// Invisible layer coexists with outlines: the searchable file carries the
|
||
// string while the outlines-only file does not, and the outlines are
|
||
// identical in both (zero visual change — the layer only adds bytes).
|
||
#[test]
|
||
fn searchable_layer_coexists_with_outlines() {
|
||
let mut outline_only = text_wire("HELLO", [20.0, 20.0, 0.0]);
|
||
outline_only.wire.searchable_text.clear();
|
||
let mut searchable = text_wire("HELLO", [20.0, 20.0, 0.0]);
|
||
searchable.wire.searchable_text =
|
||
searchable_wire("HELLO", [20.0, 20.0, 0.0]).wire.searchable_text;
|
||
|
||
// Outlines are never suppressed: both wires keep their glyph quads.
|
||
assert!(
|
||
!searchable.wire.text_verts.is_empty(),
|
||
"searchable wire must keep its outline quads"
|
||
);
|
||
|
||
let outline_bytes = build_pdf_pages(&[test_page(vec![outline_only])], None).unwrap();
|
||
let searchable_bytes = build_pdf_pages(&[test_page(vec![searchable])], None).unwrap();
|
||
let outline_stream = pdf_stream_text(&outline_bytes);
|
||
let searchable_stream = pdf_stream_text(&searchable_bytes);
|
||
|
||
assert!(
|
||
!outline_stream.contains("HELLO"),
|
||
"outline path must not leak extractable text"
|
||
);
|
||
assert!(
|
||
searchable_stream.contains("HELLO") && searchable_stream.contains("BT"),
|
||
"searchable path must carry extractable BT/Tj text"
|
||
);
|
||
assert!(
|
||
searchable_stream.contains("Tr"),
|
||
"searchable text must render invisibly (Tr)"
|
||
);
|
||
assert!(
|
||
searchable_bytes.len() > outline_bytes.len(),
|
||
"invisible layer adds bytes ({} > {}); visuals come from the kept outlines",
|
||
searchable_bytes.len(),
|
||
outline_bytes.len()
|
||
);
|
||
}
|
||
|
||
// Per-run fallback: a non-WinAnsi run (Greek/Cyrillic/CJK/symbols) emits
|
||
// no `Tj` — its outlines still draw, so nothing renders as missing glyphs.
|
||
#[test]
|
||
fn searchable_text_skips_non_winansi_runs() {
|
||
let wire = searchable_wire("ΩΩ", [20.0, 20.0, 0.0]);
|
||
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
|
||
assert!(
|
||
!stream.contains("BT"),
|
||
"non-encodable run must not emit a text object"
|
||
);
|
||
}
|
||
|
||
// Rotation must ride a text matrix (Tm), not just an angle — a 90° run
|
||
// keeps its string and emits a rotated Tm.
|
||
#[test]
|
||
fn searchable_text_rotation_uses_text_matrix() {
|
||
let mut wire = searchable_wire("AB", [20.0, 20.0, 0.0]);
|
||
wire.wire.searchable_text[0].rotation = std::f32::consts::FRAC_PI_2;
|
||
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
|
||
assert!(stream.contains("AB"), "rotated run lost its string");
|
||
assert!(stream.contains("Tm"), "rotated run needs an explicit text matrix (Tm)");
|
||
}
|
||
|
||
#[test]
|
||
fn winansi_filter_covers_accents_and_rejects_cjk() {
|
||
assert!(is_winansi_encodable("CMa-03"));
|
||
assert!(is_winansi_encodable("crème °±Ø"));
|
||
assert!(!is_winansi_encodable("Ω"));
|
||
assert!(!is_winansi_encodable("中"));
|
||
// C1 controls are unassigned in WinAnsi: emitting them would mojibake
|
||
// (explicit \u escapes — a literal control char would not survive
|
||
// editors, and NBSP U+00A0 next door *is* encodable).
|
||
assert!(is_winansi_encodable("a\u{a0}b")); // NBSP is WinAnsi 0xA0
|
||
assert!(!is_winansi_encodable("a\u{80}b"));
|
||
assert!(!is_winansi_encodable("a\u{9f}b"));
|
||
}
|
||
|
||
#[test]
|
||
fn width_factor_matches_laid_to_natural_advance() {
|
||
use std::collections::BTreeMap;
|
||
let sub = EmbeddedSubset {
|
||
family: "Test".into(),
|
||
bold: false,
|
||
font_id: printpdf::FontId::new(),
|
||
gid_of: [('A', 1), ('B', 2)].into_iter().collect::<BTreeMap<_, _>>(),
|
||
width_of: [(1, 600), (2, 620)].into_iter().collect::<BTreeMap<_, _>>(),
|
||
upem: 1000,
|
||
cap_ratio: 0.7,
|
||
};
|
||
let run = SearchableTextRun {
|
||
text: "AB".into(),
|
||
origin: [0.0, 0.0, 0.0],
|
||
height: 10.0,
|
||
rotation: 0.0,
|
||
color: [0.0, 0.0, 0.0, 1.0],
|
||
bold: false,
|
||
font: "Test".into(),
|
||
// Laid advance exactly equals natural at cap height, so k = 1:
|
||
// adv = height * Σw / (upem * cap) = 10*1220/(1000*0.7).
|
||
adv_width: 10.0 * 1220.0 / (1000.0 * 0.7),
|
||
};
|
||
assert!((sub.width_factor(&run) - 1.0).abs() < 1e-4);
|
||
let mut wide = run.clone();
|
||
wide.adv_width *= 2.0;
|
||
assert!((sub.width_factor(&wide) - 2.0).abs() < 1e-4);
|
||
let mut empty = run;
|
||
empty.adv_width = 0.0;
|
||
assert_eq!(sub.width_factor(&empty), 1.0);
|
||
}
|
||
|
||
// A TTF-backed run embeds a subset of its own family with ToUnicode —
|
||
// the DWG font, not Helvetica. Skips where no test font is installed.
|
||
#[test]
|
||
fn subset_embeds_the_runs_own_family_with_tounicode() {
|
||
let family = ["DejaVu Sans", "Liberation Sans", "Arial"]
|
||
.into_iter()
|
||
.find(|f| {
|
||
crate::scene::text::sysfont::with_face_data_weighted(
|
||
f,
|
||
fontdb::Weight::NORMAL,
|
||
|_, _| true,
|
||
)
|
||
.unwrap_or(false)
|
||
});
|
||
let Some(family) = family else {
|
||
eprintln!("no test TTF installed; skipping subset test");
|
||
return;
|
||
};
|
||
let full_len =
|
||
crate::scene::text::sysfont::with_face_data_weighted(family, fontdb::Weight::NORMAL, |data, _| {
|
||
data.len()
|
||
})
|
||
.unwrap();
|
||
let mut wire = searchable_wire("CMa-03 pincé", [20.0, 30.0, 0.0]);
|
||
wire.wire.searchable_text[0].font = family.to_string();
|
||
// Unit level: the subset is built for the run's family and maps the
|
||
// used chars (extractability source of truth — the content stream
|
||
// carries hex GIDs, resolved via ToUnicode, not literal text).
|
||
let mut probe = printpdf::PdfDocument::new("subset-probe");
|
||
let fonts =
|
||
SearchableFontSet::build(&mut probe, &[test_page(vec![wire.clone()])]);
|
||
let sub = fonts
|
||
.find(family, false)
|
||
.expect("subset embedded for the run's own family");
|
||
assert!(sub.gid_of.contains_key(&'C'), "used chars must be mapped");
|
||
assert!(sub.gid_of.contains_key(&'é'), "accents must survive subsetting");
|
||
// PDF level: ToUnicode + embedded bytes, and no Helvetica fallback.
|
||
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||
let stream = pdf_stream_text(&bytes);
|
||
assert!(
|
||
stream.contains("ToUnicode"),
|
||
"subset font must carry a ToUnicode CMap"
|
||
);
|
||
assert!(
|
||
stream.contains("FontFile2"),
|
||
"subset font must embed its (TrueType) bytes"
|
||
);
|
||
assert!(
|
||
!stream.contains("/Helvetica"),
|
||
"own-family run must not fall back to Helvetica"
|
||
);
|
||
assert!(
|
||
bytes.len() < full_len,
|
||
"subset PDF ({} bytes) must be lighter than the full font ({} bytes)",
|
||
bytes.len(),
|
||
full_len
|
||
);
|
||
}
|
||
|
||
// Runs fully outside the export window never reach the content stream.
|
||
#[test]
|
||
fn clip_culls_runs_outside_the_export_window() {
|
||
let inside = searchable_wire("IN", [20.0, 20.0, 0.0]);
|
||
let outside = searchable_wire("OUT", [500.0, 500.0, 0.0]);
|
||
// Same page, windowed clip around the origin run only.
|
||
let mut page = test_page(vec![inside, outside]);
|
||
page.clip = Some((0.0, 0.0, 100.0, 100.0));
|
||
let stream = pdf_stream_text(&build_pdf_pages(&[page], None).unwrap());
|
||
assert!(stream.contains("IN"), "inside run must survive the clip");
|
||
assert!(!stream.contains("OUT"), "outside run must be culled by the clip");
|
||
}
|
||
// A filled TrueType glyph exports as text in an embedded subset of its
|
||
// face — not as its tessellated outline, which for a CJK face is hundreds
|
||
// of triangles per glyph (a 28-sheet DFKai-SB set came out at 320 MB).
|
||
#[test]
|
||
fn truetype_text_embeds_a_subset_font() {
|
||
use crate::scene::pipeline::text_gpu::push_glyph_vertices;
|
||
use crate::scene::text::font_face::Face;
|
||
use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas, sysfont};
|
||
|
||
// Any installed TrueType family that draws 'H'; hosts without fonts skip.
|
||
let Some((family, source)) = sysfont::families().iter().find_map(|family| {
|
||
let face = Face::resolve(family);
|
||
matches!(face, Face::Ttf { .. })
|
||
.then(|| face.glyph_source('H'))
|
||
.flatten()
|
||
.map(|source| (family.clone(), source))
|
||
}) else {
|
||
eprintln!("no TrueType font installed; skipped");
|
||
return;
|
||
};
|
||
// The atlas and TEXTFILL are process-wide and other tests reset, grow
|
||
// or unfill them concurrently; a change between laying the quads out
|
||
// and exporting them invalidates their tile keys, so retry until one
|
||
// export runs against a stable, filled atlas.
|
||
let (bytes, text) = (0..20)
|
||
.find_map(|_| {
|
||
let generation = sdf_atlas::generation();
|
||
if !sdf_atlas::textfill() {
|
||
std::thread::yield_now();
|
||
return None;
|
||
}
|
||
let (quads, _) = {
|
||
let mut atlas = sdf_atlas::text_atlas().lock().unwrap();
|
||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, &family, false, "HHH")
|
||
};
|
||
assert_eq!(quads.len(), 3, "three glyphs laid out in {family}");
|
||
let mut verts = Vec::new();
|
||
push_glyph_vertices(
|
||
&mut verts,
|
||
&quads,
|
||
[20.0, 20.0, 0.0],
|
||
1.0,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
0.0,
|
||
);
|
||
let wire = PlotWire {
|
||
wire: WireModel {
|
||
text_verts: verts,
|
||
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
|
||
},
|
||
draw_depth: 0.0,
|
||
};
|
||
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||
let stable = sdf_atlas::generation() == generation && sdf_atlas::textfill();
|
||
stable.then(|| {
|
||
let text = pdf_stream_text(&bytes);
|
||
(bytes, text)
|
||
})
|
||
})
|
||
.expect("the shared glyph atlas never held still for one export");
|
||
assert!(text.contains(" Tf"), "glyphs drawn as text in a font");
|
||
assert_eq!(
|
||
text.matches(" Tm").count(),
|
||
3,
|
||
"one positioned text object per glyph"
|
||
);
|
||
assert!(
|
||
String::from_utf8_lossy(&bytes).contains("/FontFile2"),
|
||
"the TrueType program is embedded"
|
||
);
|
||
// A subset of a handful of glyphs, not the whole face.
|
||
assert!(
|
||
bytes.len() < source.font.data.len().max(200_000) / 2,
|
||
"PDF {} bytes vs font file {} bytes",
|
||
bytes.len(),
|
||
source.font.data.len()
|
||
);
|
||
}
|
||
}
|