mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-15 03:37:07 -04:00
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:
1 parent
c0f8679ba5
commit
5971553d60
16 files changed
+487
-27
No files matched your search
Generated
+203
@@ -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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user