forked from 1b-agent/opencadstudio-upstream
Merge pull request #1309 from ghost/embed-print-fonts
This commit is contained in:
commit
03f4e7cb86
29 files changed
+1833
-68
No files matched your search
@@ -0,0 +1,273 @@
|
||||
// Community font repository — detect missing SHX fonts and fetch them from
|
||||
// the project's `fonts/` folder on GitHub.
|
||||
//
|
||||
// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …).
|
||||
// When the file doesn't resolve on disk the renderer substitutes an embedded
|
||||
// LFF stroke font, which mangles drawings authored with fonts it cannot stand
|
||||
// in for. This module detects those references and downloads the real files
|
||||
// from the community folder, where anyone can contribute (see
|
||||
// `fonts/README.md` in the repository root).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
|
||||
/// The community folder's GitHub contents API (lists name + download URL).
|
||||
const REPO_API_URL: &str =
|
||||
"https://api.github.com/repos/HakanSeven12/OpenCADStudio/contents/fonts";
|
||||
const MAX_FONT_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// Local store for fonts downloaded from the community repository. Text
|
||||
/// resolution searches here after the drawing folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn fonts_dir() -> Option<PathBuf> {
|
||||
crate::config::config_dir().map(|dir| dir.join("fonts"))
|
||||
}
|
||||
|
||||
/// Web builds have no filesystem to cache fonts into.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn fonts_dir() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Where missing fonts are fetched from. The community repository is the
|
||||
/// default; a custom source is any HTTP folder - a company file server, a
|
||||
/// private GitHub repo's raw folder - where each font lives as
|
||||
/// `{base}/{file_name}`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FontSource {
|
||||
Community,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl FontSource {
|
||||
/// Empty / whitespace input selects the community repository.
|
||||
pub fn from_url(url: &str) -> Self {
|
||||
match url.trim() {
|
||||
"" => FontSource::Community,
|
||||
trimmed => FontSource::Custom(trimmed.trim_end_matches('/').to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One file listed in the community folder.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RepoFont {
|
||||
pub name: String,
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// Referenced-but-unresolvable `.shx` font files across the document's text
|
||||
/// styles. Names are deduplicated case-insensitively and reduced to the bare
|
||||
/// file name (`C:\Fonts\VNArial.shx` → `VNArial.shx`).
|
||||
pub fn missing_shx_fonts(doc: &CadDocument) -> Vec<String> {
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
let base = doc
|
||||
.source_path
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.and_then(|p| p.parent());
|
||||
for style in doc.text_styles.iter() {
|
||||
let file = style.font_file.trim();
|
||||
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
|
||||
continue;
|
||||
}
|
||||
let resolved = crate::io::resolve_image_file(file, base).is_some()
|
||||
|| local_font_file(file).is_some();
|
||||
if resolved {
|
||||
continue;
|
||||
}
|
||||
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
|
||||
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
missing.sort_by_key(|name| name.to_ascii_lowercase());
|
||||
missing
|
||||
}
|
||||
|
||||
/// A font file previously downloaded into the local fonts directory, matched
|
||||
/// case-insensitively by file name.
|
||||
pub fn local_font_file(name: &str) -> Option<PathBuf> {
|
||||
let file = name.rsplit(['/', '\\']).next()?;
|
||||
let dir = fonts_dir()?;
|
||||
let direct = dir.join(file);
|
||||
if direct.is_file() {
|
||||
return Some(direct);
|
||||
}
|
||||
let lower = file.to_ascii_lowercase();
|
||||
std::fs::read_dir(&dir)
|
||||
.ok()?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.find(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase() == lower)
|
||||
.map(|entry| entry.path())
|
||||
}
|
||||
|
||||
/// List the community folder through the GitHub contents API.
|
||||
fn fetch_repo_fonts() -> Result<Vec<RepoFont>, String> {
|
||||
let agent = crate::network::agent(std::time::Duration::from_secs(15));
|
||||
let mut response = agent
|
||||
.get(REPO_API_URL)
|
||||
.header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.call()
|
||||
.map_err(|e| format!("Could not reach the font repository: {e}"))?;
|
||||
let body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| format!("Could not read the font repository listing: {e}"))?;
|
||||
parse_contents(&body)
|
||||
}
|
||||
|
||||
/// Parse the GitHub contents-API response:
|
||||
/// `[{"name":"romans.shx","download_url":"https://…"}, …]`.
|
||||
fn parse_contents(body: &str) -> Result<Vec<RepoFont>, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Unexpected font listing: {e}"))?;
|
||||
let items = value
|
||||
.as_array()
|
||||
.ok_or_else(|| "Unexpected font-repository response.".to_string())?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let name = item.get("name")?.as_str()?.to_string();
|
||||
let url = item.get("download_url")?.as_str()?.to_string();
|
||||
(!name.is_empty() && !url.is_empty()).then_some(RepoFont {
|
||||
name,
|
||||
download_url: url,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download every `missing` font that exists in the community folder into the
|
||||
/// local fonts directory. Returns the (requested, saved-path) pairs that were
|
||||
/// actually fetched; names absent from the repository are skipped.
|
||||
pub fn download_fonts(
|
||||
missing: &[String],
|
||||
source: &FontSource,
|
||||
) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
if missing.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let repo = match source {
|
||||
FontSource::Community => Some(fetch_repo_fonts()?),
|
||||
FontSource::Custom(_) => None,
|
||||
};
|
||||
let dir = fonts_dir().ok_or_else(|| "Could not resolve the fonts folder.".to_string())?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
|
||||
let mut downloaded = Vec::new();
|
||||
for want in missing {
|
||||
let file = want.rsplit(['/', '\\']).next().unwrap_or(want);
|
||||
let url = match (source, repo.as_ref()) {
|
||||
// Community: download URLs come from the listing, matched by name.
|
||||
(FontSource::Community, Some(repo)) => {
|
||||
let lower = file.to_ascii_lowercase();
|
||||
repo.iter()
|
||||
.find(|f| f.name.to_ascii_lowercase() == lower)
|
||||
.map(|f| f.download_url.clone())
|
||||
}
|
||||
// Custom folder: the font is simply `{base}/{file_name}`; a miss
|
||||
// (404) is skipped without failing the batch.
|
||||
(FontSource::Custom(base), _) => {
|
||||
Some(format!("{base}/{}", percent_encode_path(file)))
|
||||
}
|
||||
(FontSource::Community, None) => None,
|
||||
};
|
||||
let Some(url) = url else { continue };
|
||||
let agent = crate::network::agent(std::time::Duration::from_secs(30));
|
||||
let mut response = agent
|
||||
.get(&url)
|
||||
.header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")))
|
||||
.call()
|
||||
.map_err(|e| format!("Could not download {file}: {e}"))?;
|
||||
if response.status() != 200 {
|
||||
continue;
|
||||
}
|
||||
let bytes = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_FONT_BYTES)
|
||||
.read_to_vec()
|
||||
.map_err(|e| format!("Could not download {file}: {e}"))?;
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dest = dir.join(file);
|
||||
std::fs::write(&dest, &bytes)
|
||||
.map_err(|e| format!("Could not save {}: {e}", dest.display()))?;
|
||||
downloaded.push((file.to_string(), dest));
|
||||
}
|
||||
Ok(downloaded)
|
||||
}
|
||||
|
||||
/// Escape path characters that would break a URL (spaces and non-ASCII file
|
||||
/// names are common in font sets).
|
||||
fn percent_encode_path(file: &str) -> String {
|
||||
let mut out = String::with_capacity(file.len());
|
||||
for byte in file.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_github_contents_listing() {
|
||||
let body = r#"[
|
||||
{"name":"romans.shx","path":"fonts/romans.shx",
|
||||
"download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"},
|
||||
{"name":"subset","path":"fonts/subset","type":"dir"},
|
||||
{"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"}
|
||||
]"#;
|
||||
let fonts = parse_contents(body).unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
assert_eq!(fonts[0].name, "romans.shx");
|
||||
assert!(fonts[0].download_url.ends_with("/fonts/romans.shx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_source_urls_are_trimmed_and_encoded() {
|
||||
assert_eq!(FontSource::from_url(""), FontSource::Community);
|
||||
assert_eq!(
|
||||
FontSource::from_url(" https://intra/fonts/ "),
|
||||
FontSource::Custom("https://intra/fonts".into())
|
||||
);
|
||||
assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_listing_response() {
|
||||
assert!(parse_contents("{\"message\":\"Not Found\"}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_deduplicate_by_name_case_insensitively() {
|
||||
// Two styles referencing the same missing font with different casing
|
||||
// and one whose file resolves next to the drawing must yield one hit.
|
||||
let dir = std::env::temp_dir().join("ocs_font_repo_tests");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("exists.shx"), b"stub").unwrap();
|
||||
let mut doc = CadDocument::new();
|
||||
let mk = |name: &str, file: &str| {
|
||||
let mut style = acadrust::TextStyle::new(name);
|
||||
style.font_file = file.into();
|
||||
style
|
||||
};
|
||||
let _ = doc.text_styles.add(mk("A", "MISSING1.shx"));
|
||||
let _ = doc.text_styles.add(mk("B", "missing1.SHX"));
|
||||
let _ = doc.text_styles.add(mk("C", "exists.shx"));
|
||||
doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned());
|
||||
let missing = missing_shx_fonts(&doc);
|
||||
assert_eq!(missing, vec!["MISSING1.shx".to_string()]);
|
||||
let _ = std::fs::remove_file(dir.join("exists.shx"));
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
// Default save format: DWG (AC1032 / R2018+).
|
||||
|
||||
pub mod file_association;
|
||||
pub mod font_repo;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod edit_lock;
|
||||
pub mod obj;
|
||||
pub mod ole_embed;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod single_instance;
|
||||
pub mod pdf_export;
|
||||
@@ -1555,6 +1557,23 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
|
||||
Ok((path, w, h))
|
||||
}
|
||||
|
||||
/// Pick an image file and prepare it for embedding (IMAGEEMBED). The bytes
|
||||
/// are read before returning so the OLE2FRAME payload is ready to place.
|
||||
pub async fn pick_embedded_image_file() -> Result<ole_embed::EmbeddedImage, String> {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select Image File").as_ref())
|
||||
.add_filter(crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tiff", "tif"])
|
||||
.add_filter(crate::t!("PNG").as_ref(), &["png"])
|
||||
.add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"])
|
||||
.add_filter(crate::t!("All Files").as_ref(), &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
.ok_or_else(|| "Cancelled".to_string())?;
|
||||
let name = handle.file_name();
|
||||
let bytes = handle.read().await;
|
||||
ole_embed::EmbeddedImage::from_bytes(name, bytes)
|
||||
}
|
||||
|
||||
/// Save `doc` to `path` with the given DXF version, overriding `doc.version`.
|
||||
/// Format is auto-detected from the extension (dwg / dxf).
|
||||
pub fn save_as_version(
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// Embed a raster image inside the drawing itself as an OLE2FRAME entity.
|
||||
//
|
||||
// The IMAGE mechanism (RasterImage + ImageDefinition) stores only a file path,
|
||||
// so a drawing copied to another machine loses its pictures. An OLE2FRAME
|
||||
// instead carries the encoded raster inside an OLE compound file embedded in
|
||||
// the entity data, so
|
||||
// the .dwg is self-contained. cadcodec both builds the compound storage and
|
||||
// reads it back, and the renderer already paints OLE2FRAMEs via
|
||||
// ImageModel::from_ole2frame, so this module only packs and unpacks.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use acadrust::compound_file::{
|
||||
BinaryRecord, CompoundEntry, CompoundFile, CompoundStorage, CompoundStream,
|
||||
CompoundStreamContent, StructuredStoragePayload,
|
||||
};
|
||||
use acadrust::entities::{Ole2Frame, OleFrameEnvelope, OleObjectType};
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
/// OLE compound-file stream holding OLE 1.0 native data. cadcodec's
|
||||
/// presentation extractor looks here first; the body is the encoded raster
|
||||
/// behind a 4-byte little-endian length.
|
||||
const OLE10NATIVE: &str = "\u{1}Ole10Native";
|
||||
/// Leading header marker carried verbatim by the geometry envelope; readers
|
||||
/// use it to recognise the envelope around the compound file.
|
||||
const GEOMETRY_MARKER: u16 = 1;
|
||||
/// Encoded formats the extractor recognises by magic and every DWG consumer
|
||||
/// can decode. Anything else is re-encoded as PNG before embedding.
|
||||
const PORTABLE_FORMATS: [image::ImageFormat; 3] = [
|
||||
image::ImageFormat::Png,
|
||||
image::ImageFormat::Jpeg,
|
||||
image::ImageFormat::Bmp,
|
||||
];
|
||||
|
||||
/// A raster image prepared for embedding: encoded bytes plus the metadata the
|
||||
/// placement prompts need.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EmbeddedImage {
|
||||
/// Encoded raster bytes (PNG / JPEG / BMP as stored or re-encoded).
|
||||
pub bytes: Vec<u8>,
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
/// File name the picture came from — surfaces in prompts and properties.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl EmbeddedImage {
|
||||
/// Prepare an image file for embedding. Recognised raster formats are
|
||||
/// stored as-is; anything else is decoded and re-encoded as PNG so the
|
||||
/// payload always stays renderable.
|
||||
pub fn from_file(path: &Path) -> Result<Self, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("image")
|
||||
.to_string();
|
||||
Self::from_bytes(name, bytes)
|
||||
}
|
||||
|
||||
/// Prepare already-read image bytes for embedding (web file handles).
|
||||
pub fn from_bytes(name: String, bytes: Vec<u8>) -> Result<Self, String> {
|
||||
let format = image::guess_format(&bytes).map_err(|e| e.to_string())?;
|
||||
let encoded = if PORTABLE_FORMATS.contains(&format) {
|
||||
bytes
|
||||
} else {
|
||||
encode_png(&bytes)?
|
||||
};
|
||||
let img = image::load_from_memory(&encoded).map_err(|e| e.to_string())?;
|
||||
let (pixel_width, pixel_height) = image::GenericImageView::dimensions(&img);
|
||||
Ok(Self {
|
||||
bytes: encoded,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Width : height aspect ratio; a degenerate height falls back to square.
|
||||
pub fn aspect(&self) -> f64 {
|
||||
if self.pixel_height == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.pixel_width as f64 / self.pixel_height as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_png(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?;
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut png, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
/// World-space corners for a picture placed with its bottom-left corner at
|
||||
/// `origin`, extending `width` right and `width / aspect` up — the same
|
||||
/// rectangle the IMAGE command previews.
|
||||
pub fn corners_from_placement(origin: Vector3, width: f64, aspect: f64) -> (Vector3, Vector3) {
|
||||
let width = width.max(1e-6);
|
||||
let height = width / aspect.max(1e-6);
|
||||
(
|
||||
Vector3::new(origin.x, origin.y + height, origin.z),
|
||||
Vector3::new(origin.x + width, origin.y, origin.z),
|
||||
)
|
||||
}
|
||||
|
||||
/// Pack `image` into an OLE2FRAME spanning `upper_left` → `lower_right` in
|
||||
/// world space. The picture rides in an `\x01Ole10Native` stream as OLE 1.0
|
||||
/// native data behind the geometry envelope cadcodec writes around it.
|
||||
pub fn build_embedded_ole(
|
||||
image: &EmbeddedImage,
|
||||
upper_left: Vector3,
|
||||
lower_right: Vector3,
|
||||
) -> Ole2Frame {
|
||||
// OLE 1.0 native: 4-byte little-endian payload length, then the raster.
|
||||
let mut native = Vec::with_capacity(4 + image.bytes.len());
|
||||
let _ = native.write(&(image.bytes.len() as u32).to_le_bytes());
|
||||
native.extend_from_slice(&image.bytes);
|
||||
|
||||
let file = CompoundFile {
|
||||
minor_version: 0x003E,
|
||||
major_version: 3,
|
||||
transaction_signature: 0,
|
||||
root: CompoundStorage {
|
||||
name: "Root Entry".to_string(),
|
||||
class_id: [0; 16],
|
||||
state_bits: 0,
|
||||
creation_time: 0,
|
||||
modified_time: 0,
|
||||
entries: vec![CompoundEntry::Stream(CompoundStream {
|
||||
name: OLE10NATIVE.to_string(),
|
||||
class_id: [0; 16],
|
||||
state_bits: 0,
|
||||
creation_time: 0,
|
||||
modified_time: 0,
|
||||
content: CompoundStreamContent::Binary(BinaryRecord::split(&native, 4096)),
|
||||
})],
|
||||
},
|
||||
};
|
||||
|
||||
let mut ole = Ole2Frame::new();
|
||||
ole.ole_object_type = OleObjectType::Embedded;
|
||||
ole.source_application = "OpenCADStudio".to_string();
|
||||
ole.upper_left_corner = upper_left;
|
||||
ole.lower_right_corner = lower_right;
|
||||
ole.envelope = OleFrameEnvelope::Geometry {
|
||||
marker: GEOMETRY_MARKER,
|
||||
extension_records: Vec::new(),
|
||||
};
|
||||
ole.storage = StructuredStoragePayload {
|
||||
leading_records: Vec::new(),
|
||||
compound_file: Some(file),
|
||||
trailing_records: Vec::new(),
|
||||
};
|
||||
ole
|
||||
}
|
||||
|
||||
/// Non-interactive embed used by the control API: add the entity to `doc` and
|
||||
/// return its handle. The undo snapshot is the caller's responsibility.
|
||||
pub fn add_embedded_image(
|
||||
doc: &mut acadrust::CadDocument,
|
||||
image: &EmbeddedImage,
|
||||
origin: Vector3,
|
||||
width: f64,
|
||||
) -> Result<acadrust::Handle, String> {
|
||||
let (upper_left, lower_right) = corners_from_placement(origin, width, image.aspect());
|
||||
let ole = build_embedded_ole(image, upper_left, lower_right);
|
||||
doc.add_entity(acadrust::EntityType::Ole2Frame(ole))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal valid PNG: a solid-colour 4×3 image through the `image` crate.
|
||||
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
png.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bytes_keeps_png_and_measures_pixels() {
|
||||
let embedded = EmbeddedImage::from_bytes("t.png".into(), png_bytes(4, 3)).unwrap();
|
||||
assert_eq!(embedded.pixel_width, 4);
|
||||
assert_eq!(embedded.pixel_height, 3);
|
||||
assert_eq!(embedded.bytes, png_bytes(4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bytes_reencodes_foreign_formats_as_png() {
|
||||
// TIFF starts with II*/MM* and is not in the portable set, so it must
|
||||
// come back re-encoded as PNG.
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255]));
|
||||
let mut tiff = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut tiff, image::ImageFormat::Tiff).unwrap();
|
||||
let embedded = EmbeddedImage::from_bytes("t.tif".into(), tiff.into_inner()).unwrap();
|
||||
assert!(embedded.bytes.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_payload_roundtrips_through_the_extractor() {
|
||||
let png = png_bytes(4, 3);
|
||||
let image = EmbeddedImage {
|
||||
bytes: png.clone(),
|
||||
pixel_width: 4,
|
||||
pixel_height: 3,
|
||||
name: "t.png".into(),
|
||||
};
|
||||
let ole = build_embedded_ole(
|
||||
&image,
|
||||
Vector3::new(0.0, 3.0, 0.0),
|
||||
Vector3::new(4.0, 0.0, 0.0),
|
||||
);
|
||||
let payload = ole.encoded_payload();
|
||||
match acadrust::entities::extract_presentation(&payload) {
|
||||
Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png),
|
||||
other => panic!("expected the embedded raster back, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_roundtrip_preserves_the_embedded_picture() {
|
||||
let png = png_bytes(4, 3);
|
||||
let image = EmbeddedImage {
|
||||
bytes: png.clone(),
|
||||
pixel_width: 4,
|
||||
pixel_height: 3,
|
||||
name: "t.png".into(),
|
||||
};
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
let handle =
|
||||
add_embedded_image(&mut doc, &image, Vector3::new(10.0, 10.0, 0.0), 20.0).unwrap();
|
||||
|
||||
let dir = std::env::temp_dir().join("ocs_ole_embed_tests");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("embedded_roundtrip.dwg");
|
||||
acadrust::DwgWriter::write_to_file(&path, &doc).unwrap();
|
||||
|
||||
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).unwrap();
|
||||
let reopened = reader.read().unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let ole = match reopened.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Ole2Frame(ole)) => ole,
|
||||
other => panic!("expected the Ole2Frame back, got {other:?}"),
|
||||
};
|
||||
// Corners survive: 20 wide at aspect 4:3 → 15 tall, base at (10,10).
|
||||
assert_eq!(ole.lower_right_corner, Vector3::new(30.0, 10.0, 0.0));
|
||||
assert_eq!(ole.upper_left_corner, Vector3::new(10.0, 25.0, 0.0));
|
||||
match acadrust::entities::extract_presentation(&ole.encoded_payload()) {
|
||||
Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png),
|
||||
other => panic!("expected the embedded raster back, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -375,7 +375,7 @@ fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]
|
||||
/// 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(not(target_arch = "wasm32"))]
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub fn pick_pdf_path_owned(
|
||||
stem: String,
|
||||
parent: &dyn iced::window::Window,
|
||||
@@ -392,6 +392,25 @@ pub fn pick_pdf_path_owned(
|
||||
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"))]
|
||||
|
||||
@@ -30,6 +30,7 @@ const NO_DEVICE_STORED_NAME: &str = "none_device";
|
||||
/// Recognised on read so those drawings keep plotting to PDF, and always
|
||||
/// rewritten with [`PDF_DEVICE_NAME`] on save.
|
||||
const LEGACY_PDF_LABEL: &str = "Save to PDF file…";
|
||||
const WINDOWS_PDF_PRINTER: &str = "Microsoft Print to PDF";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PlotDevice {
|
||||
@@ -67,7 +68,7 @@ impl PlotDevice {
|
||||
// the source application's own spelling: keep it rather than rewriting it.
|
||||
return (PlotDevice::None, true);
|
||||
}
|
||||
if name == LEGACY_PDF_LABEL {
|
||||
if name == LEGACY_PDF_LABEL || name.eq_ignore_ascii_case(WINDOWS_PDF_PRINTER) {
|
||||
return (PlotDevice::Pdf, false);
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
@@ -420,6 +421,10 @@ mod tests {
|
||||
PlotDevice::from_stored_name("Save to PDF file…"),
|
||||
(PlotDevice::Pdf, false)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("Microsoft Print to PDF"),
|
||||
(PlotDevice::Pdf, false)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("DWG To PDF.pc3"),
|
||||
(PlotDevice::Pdf, true)
|
||||
|
||||
+562
-25
@@ -109,11 +109,30 @@ pub fn list_printers() -> Vec<String> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
||||
use windows_sys::Win32::Graphics::Printing::{
|
||||
EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL,
|
||||
PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_ENUM_NETWORK,
|
||||
};
|
||||
|
||||
let mut names = enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)?;
|
||||
// LOCAL|CONNECTIONS misses network-discovered printers (spooler-shared and
|
||||
// per-machine connections), which is what "the plot dialog lost printers"
|
||||
// reports look like. A second NETWORK-augmented pass can only widen the
|
||||
// list: when the spooler rejects the extra flag it just yields nothing.
|
||||
if let Ok(extra) =
|
||||
enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_NETWORK)
|
||||
{
|
||||
names.extend(extra);
|
||||
names.sort();
|
||||
names.dedup();
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enumerate_printers(flags: u32) -> std::io::Result<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
||||
use windows_sys::Win32::Graphics::Printing::EnumPrintersW;
|
||||
|
||||
let mut bytes_needed = 0;
|
||||
let mut printer_count = 0;
|
||||
let mut buffer = Vec::<usize>::new();
|
||||
@@ -130,7 +149,7 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
// aligned, writable allocation of buffer_bytes bytes.
|
||||
let success = unsafe {
|
||||
EnumPrintersW(
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
4,
|
||||
data,
|
||||
@@ -320,6 +339,297 @@ pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result
|
||||
dispatch_to_printer_opts(path, opts)
|
||||
}
|
||||
|
||||
/// Raster resolution for the GDI fallback print. 300 DPI balances spool
|
||||
/// memory (a 297mm-wide page is ~3.5k pixels) against readable linework; the
|
||||
/// on-screen rasteriser runs at 150 DPI, which looks soft on paper.
|
||||
#[cfg(target_os = "windows")]
|
||||
const GDI_PRINT_DPI: f32 = 300.0;
|
||||
|
||||
/// Print by rasterising the PDF in-process (hayro) and drawing the bitmap to
|
||||
/// the printer DC with GDI. Needs no PDF application — the ShellExecute verb
|
||||
/// path only works when the registered PDF viewer exposes `print`/`printto`,
|
||||
/// and many (Edge, store readers like ASC.Pdf) register only `open`.
|
||||
/// Rotate a top-down RGBA raster 90° clockwise (width and height swap).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn rotate_rgba90(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
|
||||
let mut rotated = vec![0u8; pixels.len()];
|
||||
for y in 0..height as usize {
|
||||
for x in 0..width as usize {
|
||||
let src = (y * width as usize + x) * 4;
|
||||
// Clockwise: destination column = height-1-y, row = x.
|
||||
let dst = (x * height as usize + (height as usize - 1 - y)) * 4;
|
||||
rotated[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);
|
||||
}
|
||||
}
|
||||
rotated
|
||||
}
|
||||
|
||||
/// Fetch the printer driver's DEVMODE and flip its orientation to match the
|
||||
/// plot's aspect (`landscape` = image wider than tall). The merged DEVMODE is
|
||||
/// returned as raw bytes (drivers append private data after the struct).
|
||||
/// `None` when the driver could not be queried — CreateDC then falls back to
|
||||
/// the driver default.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn oriented_printer_devmode(device_wide: &[u16], landscape: bool) -> Option<Vec<u8>> {
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
DEVMODEW, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DMORIENT_LANDSCAPE,
|
||||
DMORIENT_PORTRAIT,
|
||||
};
|
||||
use windows_sys::Win32::Graphics::Printing::{
|
||||
ClosePrinter, DocumentPropertiesW, OpenPrinterW, PRINTER_HANDLE,
|
||||
};
|
||||
unsafe {
|
||||
let mut handle = PRINTER_HANDLE::default();
|
||||
if OpenPrinterW(device_wide.as_ptr(), &mut handle, std::ptr::null()) == 0 {
|
||||
return None;
|
||||
}
|
||||
let result = (|| {
|
||||
let needed = DocumentPropertiesW(
|
||||
std::ptr::null_mut(),
|
||||
handle,
|
||||
device_wide.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
DM_OUT_BUFFER,
|
||||
);
|
||||
if needed <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u8; needed as usize];
|
||||
let dm = buf.as_mut_ptr() as *mut DEVMODEW;
|
||||
if DocumentPropertiesW(
|
||||
std::ptr::null_mut(),
|
||||
handle,
|
||||
device_wide.as_ptr(),
|
||||
dm,
|
||||
std::ptr::null(),
|
||||
DM_OUT_BUFFER,
|
||||
) <= 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
(*dm).dmSize = std::mem::size_of::<DEVMODEW>() as u16;
|
||||
(*dm).dmFields |= DM_ORIENTATION;
|
||||
(*dm).Anonymous1.Anonymous1.dmOrientation = if landscape {
|
||||
DMORIENT_LANDSCAPE as i16
|
||||
} else {
|
||||
DMORIENT_PORTRAIT as i16
|
||||
};
|
||||
// Let the driver reconcile fields that depend on orientation
|
||||
// (paper dimensions, imageable area) before we print with it.
|
||||
if DocumentPropertiesW(
|
||||
std::ptr::null_mut(),
|
||||
handle,
|
||||
device_wide.as_ptr(),
|
||||
dm,
|
||||
dm,
|
||||
DM_IN_BUFFER | DM_OUT_BUFFER,
|
||||
) <= 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(buf)
|
||||
})();
|
||||
ClosePrinter(handle);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn gdi_raster_print(
|
||||
path: &std::path::Path,
|
||||
printer: Option<&str>,
|
||||
copies: u32,
|
||||
output_file: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
CreateDCW, DeleteDC, GetDeviceCaps, SetStretchBltMode, StretchDIBits, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BI_RGB, COLORONCOLOR, DIB_RGB_COLORS, DEVMODEW, HORZRES, SRCCOPY,
|
||||
VERTRES,
|
||||
};
|
||||
// windows-sys 0.61 groups the spooler-document calls (StartDoc/StartPage/
|
||||
// EndPage/EndDoc/DOCINFOW) under Storage::Xps, not Graphics::Gdi.
|
||||
use windows_sys::Win32::Graphics::Printing::GetDefaultPrinterW;
|
||||
use windows_sys::Win32::Storage::Xps::{EndDoc, EndPage, StartDocW, StartPage, DOCINFOW};
|
||||
|
||||
let wide = |s: &str| -> Vec<u16> { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() };
|
||||
|
||||
// Resolve the target device: the named printer or the Windows default.
|
||||
let device = match printer.map(str::trim).filter(|p| !p.is_empty()) {
|
||||
Some(name) => name.to_string(),
|
||||
None => {
|
||||
let mut needed = 0u32;
|
||||
// SAFETY: first call queries the size, second writes into a
|
||||
// buffer of exactly that size.
|
||||
unsafe { GetDefaultPrinterW(std::ptr::null_mut(), &mut needed) };
|
||||
if needed == 0 {
|
||||
return Err("No default printer is configured on Windows.".into());
|
||||
}
|
||||
let mut buf = vec![0u16; needed as usize];
|
||||
if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut needed) } == 0 {
|
||||
return Err(format!(
|
||||
"Could not query the default printer (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
String::from_utf16_lossy(&buf[..buf.iter().position(|&u| u == 0).unwrap_or(0)])
|
||||
}
|
||||
};
|
||||
|
||||
let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi(
|
||||
&path.to_string_lossy(),
|
||||
"1",
|
||||
GDI_PRINT_DPI,
|
||||
)
|
||||
.ok_or_else(|| "Could not rasterise the plot for direct printing.".to_string())?;
|
||||
|
||||
let winspool = wide("WINSPOOL");
|
||||
let device_wide = wide(&device);
|
||||
// The dialog's Landscape/Portrait choice rides in through the DEVMODE:
|
||||
// the sheet in the raster decides, and the driver's default must not
|
||||
// override it (a landscape plot into a portrait page used to shrink and
|
||||
// misorient the output).
|
||||
let dm_buf = oriented_printer_devmode(&device_wide, page.width > page.height);
|
||||
let init = dm_buf
|
||||
.as_ref()
|
||||
.map(|b| b.as_ptr().cast::<DEVMODEW>())
|
||||
.unwrap_or(std::ptr::null());
|
||||
// SAFETY: all pointers are NUL-terminated / valid for the call; the DC is
|
||||
// released below and dm_buf outlives it.
|
||||
let hdc = unsafe {
|
||||
CreateDCW(winspool.as_ptr(), device_wide.as_ptr(), std::ptr::null(), init)
|
||||
};
|
||||
if hdc.is_null() {
|
||||
return Err(format!("Could not open printer \"{device}\" for printing."));
|
||||
}
|
||||
|
||||
let result = (|| -> Result<(), String> {
|
||||
// SAFETY: hdc is a valid printer DC; each capability is a documented index.
|
||||
let (page_w, page_h) =
|
||||
unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) };
|
||||
if page_w <= 0 || page_h <= 0 {
|
||||
return Err("Printer reported no printable area.".into());
|
||||
}
|
||||
|
||||
// BGRA, top-down — what StretchDIBits expects for BI_RGB 32bpp.
|
||||
let mut bgra = page.pixels.as_ref().clone();
|
||||
for px in bgra.chunks_exact_mut(4) {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
|
||||
let mut src_w = page.width;
|
||||
let mut src_h = page.height;
|
||||
// Drivers whose DEVMODE could not be set (v4 class drivers may fail
|
||||
// the DocumentProperties query) keep their default orientation. When
|
||||
// that mismatches the plot sheet, rotating the raster 90° keeps the
|
||||
// plot at full size on the page instead of shrinking it to fit.
|
||||
if (page_w > page_h) != (src_w > src_h) {
|
||||
bgra = rotate_rgba90(&bgra, src_w, src_h);
|
||||
std::mem::swap(&mut src_w, &mut src_h);
|
||||
}
|
||||
|
||||
// Fit inside the printable area, preserving the page's aspect ratio.
|
||||
let fit_w = f64::from(page_w) / f64::from(src_w);
|
||||
let fit_h = f64::from(page_h) / f64::from(src_h);
|
||||
let scale = fit_w.min(fit_h);
|
||||
let draw_w = (f64::from(src_w) * scale).max(1.0) as i32;
|
||||
let draw_h = (f64::from(src_h) * scale).max(1.0) as i32;
|
||||
let x = (page_w - draw_w) / 2;
|
||||
let y = (page_h - draw_h) / 2;
|
||||
|
||||
let doc_name = wide("OpenCADStudio Plot");
|
||||
// An optional redirect writes the spooled output to a file instead of
|
||||
// the physical tray — how "Microsoft Print to PDF" prints silently,
|
||||
// and how the fallback proves itself in tests without wasting paper.
|
||||
let redirect = output_file.map(|p| wide(&p.to_string_lossy()));
|
||||
let info = DOCINFOW {
|
||||
cbSize: std::mem::size_of::<DOCINFOW>() as i32,
|
||||
lpszDocName: doc_name.as_ptr(),
|
||||
lpszOutput: redirect
|
||||
.as_ref()
|
||||
.map(|w| w.as_ptr())
|
||||
.unwrap_or(std::ptr::null()),
|
||||
lpszDatatype: std::ptr::null(),
|
||||
fwType: 0,
|
||||
};
|
||||
// SAFETY: info and its strings outlive every StartDoc/EndDoc call here.
|
||||
if unsafe { StartDocW(hdc, &info) } <= 0 {
|
||||
return Err(format!(
|
||||
"Print job could not start (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
unsafe { SetStretchBltMode(hdc, COLORONCOLOR) };
|
||||
let mut bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: src_w as i32,
|
||||
// Negative height: top-down rows, no vertical flip needed.
|
||||
biHeight: -(src_h as i32),
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB,
|
||||
biSizeImage: bgra.len() as u32,
|
||||
..Default::default()
|
||||
},
|
||||
bmiColors: [Default::default()],
|
||||
};
|
||||
for _ in 0..copies.max(1) {
|
||||
if unsafe { StartPage(hdc) } <= 0 {
|
||||
return Err(format!(
|
||||
"Could not start a print page (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
// StartPage resets DC attributes, so the stretch mode is set per page.
|
||||
unsafe { SetStretchBltMode(hdc, COLORONCOLOR) };
|
||||
let drawn = unsafe {
|
||||
StretchDIBits(
|
||||
hdc,
|
||||
x,
|
||||
y,
|
||||
draw_w,
|
||||
draw_h,
|
||||
0,
|
||||
0,
|
||||
src_w as i32,
|
||||
src_h as i32,
|
||||
bgra.as_ptr().cast(),
|
||||
&mut bmi,
|
||||
DIB_RGB_COLORS,
|
||||
SRCCOPY,
|
||||
)
|
||||
};
|
||||
// Failure returns GDI_ERROR (-1); a successful draw returns the
|
||||
// source height it copied (>= 0, 0 only for zero-height pages).
|
||||
if drawn < 0 {
|
||||
return Err(format!(
|
||||
"Drawing to the printer failed (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
if unsafe { EndPage(hdc) } <= 0 {
|
||||
return Err(format!(
|
||||
"Could not finish a print page (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
}
|
||||
if unsafe { EndDoc(hdc) } <= 0 {
|
||||
return Err(format!(
|
||||
"Print job could not complete (code {}).",
|
||||
unsafe { windows_sys::Win32::Foundation::GetLastError() }
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
// SAFETY: the DC was created above and used only on this thread.
|
||||
unsafe { DeleteDC(hdc) };
|
||||
result
|
||||
}
|
||||
|
||||
/// Open a file with the OS default application (used for print preview).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
|
||||
@@ -371,32 +681,51 @@ fn dispatch_to_printer_opts(
|
||||
_ => (wide("print"), None, "default printer".to_string()),
|
||||
};
|
||||
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
|
||||
for _ in 0..opts.copies.max(1) {
|
||||
let mut info = SHELLEXECUTEINFOW {
|
||||
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||
fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC,
|
||||
lpVerb: verb.as_ptr(),
|
||||
lpFile: path_wide.as_ptr(),
|
||||
lpParameters: params_ptr,
|
||||
nShow: SW_HIDE,
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { ShellExecuteExW(&mut info) } == 0 {
|
||||
let shell_code = info.hInstApp as usize;
|
||||
let code = if (1..=32).contains(&shell_code) {
|
||||
shell_code as u32
|
||||
} else {
|
||||
unsafe { GetLastError() }
|
||||
let shell = (|| -> Result<String, String> {
|
||||
for _ in 0..opts.copies.max(1) {
|
||||
let mut info = SHELLEXECUTEINFOW {
|
||||
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||
fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC,
|
||||
lpVerb: verb.as_ptr(),
|
||||
lpFile: path_wide.as_ptr(),
|
||||
lpParameters: params_ptr,
|
||||
nShow: SW_HIDE,
|
||||
..Default::default()
|
||||
};
|
||||
if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION {
|
||||
return Err(
|
||||
"Windows has no PDF application registered with Print support.".into(),
|
||||
);
|
||||
if unsafe { ShellExecuteExW(&mut info) } == 0 {
|
||||
let shell_code = info.hInstApp as usize;
|
||||
let code = if (1..=32).contains(&shell_code) {
|
||||
shell_code as u32
|
||||
} else {
|
||||
unsafe { GetLastError() }
|
||||
};
|
||||
if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION {
|
||||
return Err(
|
||||
"Windows has no PDF application registered with Print support.".into(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Windows print dispatch failed (code {code})"));
|
||||
}
|
||||
return Err(format!("Windows print dispatch failed (code {code})"));
|
||||
}
|
||||
Ok(label)
|
||||
})();
|
||||
if let Ok(sent) = shell {
|
||||
return Ok(sent);
|
||||
}
|
||||
Ok(label)
|
||||
// Fallback: hand the page straight to the spooler through GDI, with no
|
||||
// PDF application involved. Modern PDF readers (Edge, store apps)
|
||||
// register only an `open` verb, which used to leave every plot
|
||||
// failing with "no PDF application registered".
|
||||
gdi_raster_print(path, opts.printer.as_deref(), opts.copies, None).map_err(|gdi| {
|
||||
format!(
|
||||
"{}; direct print fallback failed: {gdi}",
|
||||
shell.err().unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
Ok(opts
|
||||
.printer
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default printer".to_string()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -478,7 +807,215 @@ fn dispatch_to_printer_opts(
|
||||
mod printer_properties_tests {
|
||||
use super::printer_properties_command;
|
||||
|
||||
/// The GDI fallback rasterises the plot PDF in-process; prove the 300 DPI
|
||||
/// path yields a page sized for print rather than the screen's 150 DPI.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn gdi_fallback_rasterises_the_plot_pdf_at_print_dpi() {
|
||||
use printpdf::{Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions};
|
||||
|
||||
let mut document = PdfDocument::new("gdi print test");
|
||||
document
|
||||
.pages
|
||||
.push(OutputPage::new(Mm(210.0), Mm(148.0), Vec::new()));
|
||||
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
|
||||
let path = std::env::temp_dir().join("ocs_gdi_print_probe.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
|
||||
let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi(
|
||||
&path.to_string_lossy(),
|
||||
"1",
|
||||
super::GDI_PRINT_DPI,
|
||||
)
|
||||
.expect("plot PDF should rasterise");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
assert_eq!(page.dpi, super::GDI_PRINT_DPI);
|
||||
// 210mm at 300 DPI ≈ 2480 px, aspect 210:148.
|
||||
assert!((page.width as f64 - 210.0 / 25.4 * 300.0).abs() < 2.0);
|
||||
assert!((page.height as f64 - 148.0 / 25.4 * 300.0).abs() < 2.0);
|
||||
assert_eq!(page.pixels.len(), page.width as usize * page.height as usize * 4);
|
||||
}
|
||||
|
||||
/// Opening the printer DC and reading its printable area must work with
|
||||
/// the device-name resolution the GDI fallback uses. No job is spooled.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn gdi_fallback_opens_a_printer_dc() {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
CreateDCW, DeleteDC, GetDeviceCaps, HORZRES, VERTRES,
|
||||
};
|
||||
|
||||
let wide = |s: &str| -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
|
||||
};
|
||||
let winspool = wide("WINSPOOL");
|
||||
let device = wide("Microsoft Print to PDF");
|
||||
// SAFETY: NUL-terminated strings; the DC is released before returning.
|
||||
let hdc = unsafe {
|
||||
CreateDCW(winspool.as_ptr(), device.as_ptr(), std::ptr::null(), std::ptr::null())
|
||||
};
|
||||
assert!(!hdc.is_null(), "printer DC should open");
|
||||
// SAFETY: capability indexes on a valid DC.
|
||||
let (w, h) = unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) };
|
||||
// SAFETY: releasing the DC opened above.
|
||||
unsafe { DeleteDC(hdc) };
|
||||
assert!(w > 0 && h > 0, "printable area should be reported, got {w}x{h}");
|
||||
}
|
||||
|
||||
/// End to end through the real spooler: plot a page with black ink, run
|
||||
/// the GDI fallback against "Microsoft Print to PDF" redirected to a file
|
||||
/// (no dialog, no paper), then rasterise the result and require that ink
|
||||
/// actually landed on the page. Catches off-page draws (the blank-paper
|
||||
/// regression) without a physical printer.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn gdi_fallback_lands_ink_on_the_printed_page() {
|
||||
use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb};
|
||||
|
||||
let mut document = PdfDocument::new("gdi ink test");
|
||||
let mut ops = Vec::new();
|
||||
// A big black block in the middle of an A4 page.
|
||||
ops.push(printpdf::Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }),
|
||||
});
|
||||
// printpdf 0.9's DrawRectangle never paints (its serializer always
|
||||
// ends the path with `n`), so the ink must be a filled polygon — the
|
||||
// same op the plot pipeline draws hatches with.
|
||||
ops.push(printpdf::Op::DrawPolygon {
|
||||
polygon: printpdf::Polygon {
|
||||
rings: vec![printpdf::PolygonRing {
|
||||
points: vec![
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(60.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(60.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(100.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(100.0).into() }, bezier: false },
|
||||
],
|
||||
}],
|
||||
mode: printpdf::PaintMode::Fill,
|
||||
winding_order: printpdf::WindingOrder::NonZero,
|
||||
},
|
||||
});
|
||||
document
|
||||
.pages
|
||||
.push(OutputPage::new(Mm(210.0), Mm(148.0), ops));
|
||||
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
|
||||
let plot = std::env::temp_dir().join("ocs_gdi_ink_source.pdf");
|
||||
std::fs::write(&plot, &bytes).unwrap();
|
||||
|
||||
let printed = std::env::temp_dir().join("ocs_gdi_ink_output.pdf");
|
||||
let _ = std::fs::remove_file(&printed);
|
||||
|
||||
let result = super::gdi_raster_print(
|
||||
&plot,
|
||||
Some("Microsoft Print to PDF"),
|
||||
1,
|
||||
Some(&printed),
|
||||
);
|
||||
assert!(result.is_ok(), "GDI print failed: {result:?}");
|
||||
|
||||
// The spooled PDF must contain the black block: rasterise it and
|
||||
// count clearly-dark pixels.
|
||||
let page = crate::scene::model::pdf_raster::rasterize_page(
|
||||
&printed.to_string_lossy(),
|
||||
"1",
|
||||
)
|
||||
.expect("spooled output should rasterise");
|
||||
let dark = page
|
||||
.pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|px| px[0] < 100 && px[1] < 100 && px[2] < 100)
|
||||
.count();
|
||||
std::fs::remove_file(&plot).ok();
|
||||
std::fs::remove_file(&printed).ok();
|
||||
assert!(
|
||||
dark > page.width as usize * page.height as usize / 50,
|
||||
"printed page should carry ink; only {dark} dark pixels"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
/// The plot sheet's orientation must drive the printer's DEVMODE: a
|
||||
/// landscape sheet spooled through "Microsoft Print to PDF" (whose
|
||||
/// default is portrait) has to come out as a landscape page.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn gdi_fallback_prints_landscape_sheets_landscape() {
|
||||
use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb};
|
||||
|
||||
let mut document = PdfDocument::new("gdi orientation test");
|
||||
let mut ops = Vec::new();
|
||||
ops.push(printpdf::Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }),
|
||||
});
|
||||
ops.push(printpdf::Op::DrawPolygon {
|
||||
polygon: printpdf::Polygon {
|
||||
rings: vec![printpdf::PolygonRing {
|
||||
points: vec![
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(20.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(20.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(45.0).into() }, bezier: false },
|
||||
printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(45.0).into() }, bezier: false },
|
||||
],
|
||||
}],
|
||||
mode: printpdf::PaintMode::Fill,
|
||||
winding_order: printpdf::WindingOrder::NonZero,
|
||||
},
|
||||
});
|
||||
// Landscape A4: 297 × 210 mm.
|
||||
document.pages.push(OutputPage::new(Mm(297.0), Mm(210.0), ops));
|
||||
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
|
||||
let plot = std::env::temp_dir().join("ocs_gdi_orientation_source.pdf");
|
||||
std::fs::write(&plot, &bytes).unwrap();
|
||||
let printed = std::env::temp_dir().join("ocs_gdi_orientation_output.pdf");
|
||||
let _ = std::fs::remove_file(&printed);
|
||||
|
||||
let result = super::gdi_raster_print(&plot, Some("Microsoft Print to PDF"), 1, Some(&printed));
|
||||
assert!(result.is_ok(), "GDI print failed: {result:?}");
|
||||
|
||||
let page = crate::scene::model::pdf_raster::rasterize_page(
|
||||
&printed.to_string_lossy(),
|
||||
"1",
|
||||
)
|
||||
.expect("spooled output should rasterise");
|
||||
std::fs::remove_file(&plot).ok();
|
||||
std::fs::remove_file(&printed).ok();
|
||||
|
||||
// Whichever mechanism wins — DEVMODE orientation or the raster
|
||||
// rotation fallback — the ink must land on the page and the plot's
|
||||
// long side must run along the page's long side.
|
||||
let mut min_x = page.width;
|
||||
let mut max_x = 0u32;
|
||||
let mut min_y = page.height;
|
||||
let mut max_y = 0u32;
|
||||
let mut dark = 0usize;
|
||||
for y in 0..page.height {
|
||||
for x in 0..page.width {
|
||||
let px = &page.pixels[((y * page.width + x) * 4) as usize..((y * page.width + x) * 4 + 3) as usize];
|
||||
if px.iter().all(|&c| c < 100) {
|
||||
dark += 1;
|
||||
min_x = min_x.min(x);
|
||||
max_x = max_x.max(x);
|
||||
min_y = min_y.min(y);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(dark > 1_000, "printed page must carry ink; {dark} dark pixels");
|
||||
let ink_w = (max_x - min_x + 1) as f64;
|
||||
let ink_h = (max_y - min_y + 1) as f64;
|
||||
assert_eq!(
|
||||
ink_w > ink_h,
|
||||
page.width > page.height,
|
||||
"ink aspect must follow the page: ink {:.0}x{:.0} on page {}x{}",
|
||||
ink_w,
|
||||
ink_h,
|
||||
page.width,
|
||||
page.height
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_opens_selected_printer_or_printer_list() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in new issue
Block a user