H: place SVG (vector PDF export), PSD, BMP, AI — designcraft-images crate

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 10:51:48 -04:00
1 parent c0f8679ba5
commit 5971553d60
16 files changed
+487 -27

No files matched your search

Generated
+203
View File
@@ -830,6 +830,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -989,6 +995,16 @@ dependencies = [
"zip",
]
[[package]]
name = "designcraft-images"
version = "0.1.0"
dependencies = [
"designcraft-fonts",
"image",
"psd",
"resvg",
]
[[package]]
name = "designcraft-mcp"
version = "0.1.0"
@@ -1011,11 +1027,13 @@ dependencies = [
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"image",
"krilla",
"krilla-svg",
"kurbo",
"log",
"thiserror 2.0.21",
@@ -1030,6 +1048,7 @@ dependencies = [
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"hayro",
"image",
"log",
@@ -1516,6 +1535,29 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2319,6 +2361,22 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "krilla-svg"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16"
dependencies = [
"flate2",
"fontdb",
"krilla",
"resvg",
"skrifa 0.42.1",
"smallvec",
"tiny-skia",
"usvg",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3074,6 +3132,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -3253,6 +3317,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psd"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3497,6 +3570,23 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "resvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rfd"
version = "0.17.2"
@@ -3524,6 +3614,30 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3747,6 +3861,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.4"
@@ -3905,6 +4028,9 @@ name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "subsetter"
@@ -3918,6 +4044,16 @@ dependencies = [
"write-fonts",
]
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -4036,6 +4172,21 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
@@ -4057,6 +4208,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -4184,6 +4341,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
@@ -4226,6 +4389,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -4244,6 +4413,34 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.21.1",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
"ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5260,6 +5457,12 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xmp-writer"
version = "0.3.3"
+5 -1
View File
@@ -45,7 +45,11 @@ skrifa = "0.47"
harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
# SVG (same usvg as krilla-svg) and Photoshop composites.
resvg = { version = "0.47", default-features = false, features = ["text"] }
krilla-svg = "0.8"
psd = "0.3"
krilla = { version = "0.8", features = ["pdf"] }
hayro = "0.7"
quick-xml = "0.38"
+1
View File
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd composite), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
+1 -1
View File
@@ -5,7 +5,7 @@ use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf", "txt", "docx", "rtf", "md"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "txt", "docx", "rtf", "md"];
const CANVAS_ID: &str = "designcraft_canvas";
const LOADING_ID: &str = "designcraft_loading";
+6 -3
View File
@@ -82,9 +82,12 @@ fn services() -> Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
let d = if purpose == "place" {
d.add_filter("Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf", "txt", "docx", "rtf", "md"])
.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "pdf"])
.add_filter("Text (Word, RTF, plain)", &["docx", "rtf", "txt", "md"])
d.add_filter(
"Graphics and text",
&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "txt", "docx", "rtf", "md"],
)
.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai"])
.add_filter("Text (Word, RTF, plain)", &["docx", "rtf", "txt", "md"])
} else {
d.add_filter("DesignCraft or IDML", &["designcraft", "idml"])
.add_filter("DesignCraft", &["designcraft"])
+2 -2
View File
@@ -114,8 +114,8 @@ fn list(d: &Document) -> Vec<Value> {
let loc = d.find(id)?;
let it = d.item_at(&loc)?;
let Content::Graphic(g) = &it.content else { return None };
// Vector (placed PDF): no effective resolution.
if a.mime == "application/pdf" {
// Vector (placed PDF or SVG): no effective resolution.
if a.mime == "application/pdf" || a.mime == "image/svg+xml" {
return None;
}
let (pw, _) = a.pixels?;
+30
View File
@@ -223,4 +223,34 @@ mod place_pdf_tests {
assert!(!text.contains("/Subtype /Image") && !text.contains("/Subtype/Image"), "not rasterized");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn place_svg_on_screen_and_as_vectors_in_pdf() {
let dir = std::env::temp_dir().join(format!("dc-place-svg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("logo.svg");
std::fs::write(
&src,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="160" height="80"><rect width="80" height="80" fill="#0000ff"/></svg>"##,
)
.unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": src.to_string_lossy(), "x": 100, "y": 100})).unwrap();
let d = s.doc().unwrap().doc.clone();
let b = d.spreads[0].items.last().unwrap().bounds();
assert!((b.width() - 120.0).abs() < 1.0 && (b.height() - 60.0).abs() < 1.0, "160×80 px = 120×60 pt: {b:?}");
assert_eq!(d.assets.values().next().unwrap().mime, "image/svg+xml");
let mut rr = designcraft_render::Renderer::new();
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
let p = img.pixel(130, 130);
assert!(p[2] > 200 && p[0] < 60, "blue square on screen: {p:?}");
let out = dir.join("out.pdf");
s.execute("file.exportPdf", &json!({"path": out.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&out).unwrap();
hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).expect("valid pdf");
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("/Subtype /Image") && !text.contains("/Subtype/Image"), "not rasterized");
let _ = std::fs::remove_dir_all(&dir);
}
}
+5
View File
@@ -14,6 +14,11 @@ use skrifa::{GlyphId, MetadataProvider};
/// The family used when a requested family is unknown (the UI sans).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
/// The bundled fonts (OFL), as font file bytes.
pub fn bundled() -> &'static [&'static [u8]] {
BUNDLED
}
static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
+1 -1
View File
@@ -8,7 +8,7 @@
mod fontdb;
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, bundled};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
pub use kurbo::BezPath;
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "designcraft-images"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft placed-graphic decoding: raster formats (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD) and SVG"
[lints]
workspace = true
[dependencies]
designcraft-fonts = { workspace = true }
image = { workspace = true }
resvg = { workspace = true }
psd = { workspace = true }
+174
View File
@@ -0,0 +1,174 @@
//! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export.
//! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or `psd` (Photoshop's merged
//! composite); SVG is parsed with usvg (text set in the bundled fonts) and rasterised with resvg
//! for the screen — PDF export draws the same tree as vectors.
use std::sync::{Arc, OnceLock};
pub use resvg::usvg;
/// CSS pixels (SVG user units) → points.
pub const PT_PER_PX: f64 = 0.75;
pub fn is_pdf(bytes: &[u8]) -> bool {
bytes.starts_with(b"%PDF")
}
pub fn is_psd(bytes: &[u8]) -> bool {
bytes.starts_with(b"8BPS")
}
/// An SVG document (optionally after an XML declaration, comments or a doctype).
pub fn is_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(4096)];
let s = String::from_utf8_lossy(head);
let t = s.trim_start_matches('\u{feff}').trim_start();
t.starts_with('<') && t.contains("<svg")
}
/// MIME type of a placed graphic.
pub fn mime(bytes: &[u8]) -> &'static str {
if is_pdf(bytes) {
return "application/pdf";
}
if is_psd(bytes) {
return "image/vnd.adobe.photoshop";
}
if is_svg(bytes) {
return "image/svg+xml";
}
match image::guess_format(bytes) {
Ok(image::ImageFormat::Png) => "image/png",
Ok(image::ImageFormat::Jpeg) => "image/jpeg",
Ok(image::ImageFormat::Gif) => "image/gif",
Ok(image::ImageFormat::WebP) => "image/webp",
Ok(image::ImageFormat::Tiff) => "image/tiff",
Ok(image::ImageFormat::Bmp) => "image/bmp",
_ => "application/octet-stream",
}
}
fn svg_options() -> &'static usvg::Options<'static> {
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
OPTS.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
for f in designcraft_fonts::bundled() {
db.load_font_data(f.to_vec());
}
db.set_serif_family("Source Serif 4");
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), ..Default::default() }
})
}
/// The parsed SVG.
pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
usvg::Tree::from_data(bytes, svg_options()).ok()
}
/// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs
/// likewise, SVGs from their width/height (CSS pixels), PDFs are the caller's.
pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
if is_svg(bytes) {
let s = svg_tree(bytes)?.size();
return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX));
}
let (w, h) = pixel_size(bytes)?;
Some((w as f64, h as f64))
}
/// Pixel dimensions of a raster.
pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
if is_psd(bytes) && bytes.len() >= 26 {
// Header: signature, version, 6 reserved, channels, height, width (big-endian).
let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
return Some((be(18), be(14)));
}
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// Straight (non-premultiplied) RGBA8 of a raster.
pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
if is_psd(bytes) {
let p = psd::Psd::from_bytes(bytes).ok()?;
return image::RgbaImage::from_raw(p.width(), p.height(), p.rgba());
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
}
/// An SVG rasterised so its longer side is `max_side` pixels: premultiplied RGBA8.
pub fn render_svg(bytes: &[u8], max_side: u32) -> Option<(Vec<u8>, u32, u32)> {
let tree = svg_tree(bytes)?;
let s = tree.size();
let k = max_side as f32 / s.width().max(s.height()).max(1e-3);
let (w, h) = ((s.width() * k).round().max(1.0) as u32, (s.height() * k).round().max(1.0) as u32);
let mut pm = resvg::tiny_skia::Pixmap::new(w, h)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(k, k), &mut pm.as_mut());
Some((pm.take(), w, h))
}
#[cfg(test)]
mod tests {
use super::*;
const SVG: &str = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<rect x="0" y="0" width="100" height="100" fill="#ff0000"/>
<text x="110" y="60" font-size="20">Hi</text>
</svg>"##;
#[test]
fn svg_sniff_size_and_render() {
let b = SVG.as_bytes();
assert!(is_svg(b));
assert_eq!(mime(b), "image/svg+xml");
assert_eq!(natural_size(b), Some((150.0, 75.0)));
let (px, w, h) = render_svg(b, 400).unwrap();
assert_eq!((w, h), (400, 200));
// Red on the left; the text drew some ink on the right.
assert_eq!(&px[(100 * 400 + 100) * 4..][..4], &[255, 0, 0, 255]);
assert!(px.chunks(4).skip(220).step_by(7).any(|p| p[3] > 128 && p[0] < 100), "text pixels");
assert!(!is_svg(b"\x89PNG"));
}
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec<u8> {
let mut b = Vec::new();
b.extend(b"8BPS");
b.extend(1u16.to_be_bytes());
b.extend([0u8; 6]);
b.extend(3u16.to_be_bytes()); // channels
b.extend(1u32.to_be_bytes()); // height
b.extend(2u32.to_be_bytes()); // width
b.extend(8u16.to_be_bytes()); // depth
b.extend(3u16.to_be_bytes()); // RGB
b.extend(0u32.to_be_bytes()); // colour mode data
b.extend(0u32.to_be_bytes()); // image resources
b.extend(0u32.to_be_bytes()); // layer and mask info
b.extend(0u16.to_be_bytes()); // raw
b.extend([255, 0, 0, 255, 0, 0]); // R, G, B planes: a red pixel, then a green one
b
}
#[test]
fn psd_composite_decodes() {
let b = tiny_psd();
assert_eq!(mime(&b), "image/vnd.adobe.photoshop");
assert_eq!(pixel_size(&b), Some((2, 1)));
let img = decode_rgba(&b).unwrap();
assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(img.get_pixel(1, 0).0, [0, 255, 0, 255]);
}
#[test]
fn bmp_decodes() {
let mut bmp = Vec::new();
image::RgbaImage::from_pixel(3, 2, image::Rgba([0, 0, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp)
.unwrap();
assert_eq!(mime(&bmp), "image/bmp");
assert_eq!(pixel_size(&bmp), Some((3, 2)));
assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]);
}
}
+2
View File
@@ -15,6 +15,8 @@ designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
designcraft-compose = { workspace = true }
krilla = { workspace = true }
krilla-svg = { workspace = true }
designcraft-images = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+24 -1
View File
@@ -147,6 +147,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
warnings,
images: HashMap::new(),
pdfs: HashMap::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: archival.is_some(),
@@ -227,6 +228,8 @@ pub(crate) struct Exporter<'a> {
images: HashMap<AssetId, Option<Image>>,
/// Placed PDFs (embedded as vector pages).
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
/// Parsed placed SVGs.
svgs: HashMap<AssetId, Option<Arc<designcraft_images::usvg::Tree>>>,
pub fonts: HashMap<u32, Option<krilla::text::Font>>,
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
@@ -741,6 +744,25 @@ impl Exporter<'_> {
}
return;
}
// Placed SVGs go in as vectors too.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_images::is_svg(&asset.data)
{
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
let tree = self.svgs.entry(g.asset).or_insert_with(|| designcraft_images::svg_tree(&asset.data).map(Arc::new)).clone();
match tree {
Some(tree) => {
use krilla_svg::SurfaceExt;
s.push_transform(&tf(g.xf));
if s.draw_svg(&tree, size, krilla_svg::SvgSettings::default()).is_none() {
self.warn(format!("{}: parts of the placed SVG could not be drawn", asset.name));
}
s.pop();
}
None => self.warn(format!("{}: can't read the placed SVG", asset.name)),
}
return;
}
let Some(img) = self.load_image(g.asset) else { return };
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
s.push_transform(&tf(g.xf));
@@ -757,7 +779,8 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Imag
_ => None,
};
direct.or_else(|| {
let rgba = image::load_from_memory(data).ok()?.to_rgba8();
// TIFF, BMP, PSD composites…
let rgba = designcraft_images::decode_rgba(data)?;
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
+1
View File
@@ -18,6 +18,7 @@ vello_cpu = { workspace = true }
vello_common = { workspace = true }
image = { workspace = true }
hayro = { workspace = true }
designcraft-images = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
+12 -13
View File
@@ -652,7 +652,12 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
if is_pdf(bytes) {
return render_pdf_page(bytes, 0, 3000);
}
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
if designcraft_images::is_svg(bytes) {
let (px, w, h) = designcraft_images::render_svg(bytes, 3000)?;
let data = px.chunks_exact(4).map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect();
return Some(Pixmap::from_parts(data, w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16));
}
let img = designcraft_images::decode_rgba(bytes)?;
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
@@ -708,7 +713,11 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
// Placed PDFs are sized by their page (crop box) in points.
return pdf_page_size(bytes, 0).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32));
}
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
if designcraft_images::is_svg(bytes) {
// SVGs by their width and height (CSS pixels → points).
return designcraft_images::natural_size(bytes).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32));
}
designcraft_images::pixel_size(bytes)
}
/// MIME type guess for encoded image bytes.
@@ -747,17 +756,7 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixma
}
pub fn image_mime(bytes: &[u8]) -> &'static str {
if is_pdf(bytes) {
return "application/pdf";
}
match image::guess_format(bytes) {
Ok(image::ImageFormat::Png) => "image/png",
Ok(image::ImageFormat::Jpeg) => "image/jpeg",
Ok(image::ImageFormat::Gif) => "image/gif",
Ok(image::ImageFormat::WebP) => "image/webp",
Ok(image::ImageFormat::Tiff) => "image/tiff",
_ => "application/octet-stream",
}
designcraft_images::mime(bytes)
}
#[cfg(test)]
+5 -5
View File
@@ -5,11 +5,11 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 69%** over 275 features.
**Weighted breadth parity: 70%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 97 | 5 | 0 | 98% |
| P0 | 102 | 98 | 4 | 0 | 98% |
| P1 | 97 | 33 | 23 | 41 | 46% |
| P2 | 76 | 8 | 7 | 61 | 15% |
@@ -22,7 +22,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Transform | 78% |
| Fill, stroke, colour | 69% |
| Effects & transparency | 50% |
| Placing & links | 62% |
| Placing & links | 66% |
| Type & text frames | 77% |
| Typography | 79% |
| Styles | 61% |
@@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Undo, history, saving | 100% |
| Accessibility | 20% |
Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print).
Open P0 items: Dockable panels, Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print).
## Rows
@@ -168,7 +168,7 @@ Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats
| Effects & transparency | Transparency flattener | P2 | M |
| Placing & links | Place | P0 | D |
| Placing & links | Place into frame / by click or drag | P0 | D |
| Placing & links | Raster formats | P0 | P |
| Placing & links | Raster formats | P0 | D |
| Placing & links | Vector/page formats (PDF/AI/SVG/EPS place) | P0 | P |
| Placing & links | Placing INDD/IDML pages | P2 | M |
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | D |